feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page - #478

Merged
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer
Jul 30, 2026
Merged

feat(analytics): #121 data layer — bucket param, typed client, hooks, raw admin page#478
AndresL230 merged 3 commits into
mainfrom
feat/b9-121-analytics-data-layer

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #121 (left open per closure policy — Andres verifies the UI surface and closes).

Backend (small, approved addition to the #120 API)

  • Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse UTC-day series (client zero-fills from range). The errors series runs its own capped scan and surfaces truncated — never silent partial data.
  • Range now serializes {"from", "to"} (was the accidental Python field name from_) — fixed before any TS client froze on it.

Frontend

  • Typed wrappers + response types (distinct from the legacy AnalyticsOverview family) + thin data hooks on the house useCallback+useEffect pattern; presetRange uses lib/testMode's clock.
  • Admin-gated /admin/analytics rendering live data as raw tables (per [P3] Observability: frontend admin analytics data layer (client + hooks + route) #121 — charts are [P3] Observability: admin analytics dashboard UI (charts/visualizations) #122): usage summary, top users, LLM cost with group-by toggle, errors. Shared range selector (7d/30d/90d presets + custom dates) drives every panel; per-panel error && !data banner + retry; truncation badges.
  • Admin gate outside the hook-owning body — a non-admin visit fires zero requests (was: four 403s each minting an auth.permission_denied audit event).
  • Testids registered in BOTH docs/frontend-testids.md and the eslint no-restricted-syntax files array.

Tests

  • Backend: 7 new route tests (bucket series ×3 endpoints, wire-format, invalid bucket 422, series-scan truncation) — TDD red-first; suite 1509 passed.
  • Frontend: 13 new (wrapper URL contracts, presetRange math, screen gate/panels/retry/range/truncation) — 369 passed, tsc clean, lint 0 errors.

The promoted UI journey lands with #122 (same surface, final UI) — API-level e2e coverage already exists in events.spec.ts. Full local e2e cycle runs before merge regardless.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added an admin-only analytics dashboard with date presets and custom date ranges.
    • Added usage, user activity, LLM cost, and error reporting panels.
    • Added daily analytics series for usage, costs, and errors.
    • Added independent loading, empty, error, retry, and truncation states for each panel.
  • Bug Fixes

    • Corrected analytics range serialization to use the expected from and to parameters.
  • Documentation

    • Documented analytics dashboard test identifiers.
  • Tests

    • Added coverage for dashboard access, filtering, retries, validation, daily series, and truncation handling.

AndresL230and others added 2 commits July 30, 2026 10:28
…#121 backend)
- Optional ?bucket=day on /usage/summary, /llm/cost, /errors adds a sparse
UTC-day series (days with no rows omitted; client zero-fills from range).
The errors series runs its own capped scan and surfaces truncation.
- Range now serializes as {"from", "to"} — the query param and the wire key
agree before any TS client freezes on the accidental "from_".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#121)
- Typed wrappers (adminUsageSummary/ByUser/LlmCost/Errors) over the #120 API,
omitting unset params so backend defaults stay server-owned; response
types in types.ts named clear of the legacy AnalyticsOverview family.
- Data hooks (useAdminAnalytics.ts) on the house useCallback+useEffect
pattern; presetRange routes through lib/testMode's clock.
- /admin/analytics renders live data as raw tables: usage summary, top
users, LLM cost with group-by toggle, error feed. One range drives every
panel; per-panel error && !data banners with retry; truncation badges.
The admin gate sits OUTSIDE the hook-owning body so a non-admin visit
fires zero 403ing requests.
- admin-analytics testid surface registered in BOTH docs/frontend-testids.md
and the eslint no-restricted-syntax files array.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8682b06f-c281-4b74-8e47-53bd348c9f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 010164b and 85b916e.

📒 Files selected for processing (4)
  • docs/frontend-testids.md
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/useAdminAnalytics.ts
📝 Walkthrough

Walkthrough

Changes

Admin analytics now includes backend day-bucketed series and truncation reporting, typed frontend API access and hooks, and a new admin-gated dashboard with date controls, four data panels, retries, and truncation indicators.

Admin analytics backend

Layer / File(s)Summary
Analytics contracts and day-series aggregation
backend/routes/admin_analytics.py
Response models serialize from correctly and support usage, cost, and error day series generated from UTC calendar days.
Endpoint integration and validation
backend/routes/admin_analytics.py, backend/tests/test_admin_analytics_routes.py
Usage, LLM cost, and errors accept bucket=day; error series use a separate capped scan, with tests covering serialization, validation, aggregates, and truncation.

Frontend analytics data flow

Layer / File(s)Summary
Analytics types and API wrappers
frontend/src/lib/types.ts, frontend/src/lib/api.ts, frontend/src/lib/adminAnalyticsApi.test.ts
Frontend response models and endpoint helpers cover ranges, pagination, grouping, optional buckets, series, and truncation while omitting unset query parameters.
Analytics query hooks
frontend/src/lib/useAdminAnalytics.ts, frontend/src/lib/useAdminAnalytics.test.ts
Shared query state management, retry loading, humanized errors, preset ranges, and endpoint-specific hooks are added and tested.

Admin dashboard UI

Layer / File(s)Summary
Dashboard page and panel rendering
frontend/src/app/(shell)/admin/analytics/page.tsx, frontend/src/components/screens/AdminAnalytics.tsx
A new route renders a staff-only dashboard with shared date controls and independent Usage, Top users, LLM cost, and Errors panels.
UI behavior validation and test IDs
frontend/src/components/screens/AdminAnalytics.test.tsx, frontend/eslint.config.mjs, docs/frontend-testids.md
Tests cover access gating, rendering, retries, range changes, and truncation; test-ID enforcement and documentation include the new surface.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • SaplingLearn/Sapling#375 — Introduces the admin analytics endpoints that this PR extends with day-bucketed series and truncation behavior.

Suggested reviewers:darkest-teddy

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 7.69% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately summarizes the main change set: analytics bucket support, typed client/hooks, and the new admin page.
Description check✅ PassedThe description covers the summary, backend/frontend changes, testing, and reviewer notes, though it doesn't follow the exact template headings.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b9-121-analytics-data-layer

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging85b916eCommit Preview URL

Branch Preview URL
Jul 30 2026, 06:04 PM

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/tests/test_admin_analytics_routes.py`:
- Around line 270-339: Move the mocked Supabase setup currently owned by the
local seeded fixture into the shared seeded fixture in tests/conftest.py,
including the analytics.table patch and common seed data. Remove the duplicate
local setup from these analytics route tests so all cases use the shared mocked
Supabase and Gemini fixtures consistently.
In `@frontend/src/lib/useAdminAnalytics.ts`:
- Around line 58-74: Update the load callback to assign each invocation a
monotonically increasing request id, clear existing data when starting a new
request, and guard setData, setError, and setLoading updates so only the latest
request can change state. Ensure an older completion cannot overwrite the
selected range and that a failed latest request does not retain stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f3306530-f696-4d48-9873-496cc4e0ee36

📥 Commits

Reviewing files that changed from the base of the PR and between d1d724f and 010164b.

📒 Files selected for processing (12)
  • backend/routes/admin_analytics.py
  • backend/tests/test_admin_analytics_routes.py
  • docs/frontend-testids.md
  • frontend/eslint.config.mjs
  • frontend/src/app/(shell)/admin/analytics/page.tsx
  • frontend/src/components/screens/AdminAnalytics.test.tsx
  • frontend/src/components/screens/AdminAnalytics.tsx
  • frontend/src/lib/adminAnalyticsApi.test.ts
  • frontend/src/lib/api.ts
  • frontend/src/lib/types.ts
  • frontend/src/lib/useAdminAnalytics.test.ts
  • frontend/src/lib/useAdminAnalytics.ts

Comment on lines +270 to +339
def test_range_serializes_as_from(seeded):
# The wire key must be "from" (matching the query param), not the Python
# field name "from_" — the TS client types freeze on this shape.
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["range"] == {"from": RANGE["from"], "to": RANGE["to"]}


def test_usage_summary_bucket_day_series(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert [p["date"] for p in body["series"]] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["count"] for p in body["series"]] == [1, 1, 2]
# Bucketing adds the series; it must not change the aggregate fields.
assert body["total_events"] == 4
assert body["distinct_active_users"] == 2


def test_usage_summary_without_bucket_has_no_series(seeded):
r = client.get(f"{BASE}/usage/summary", params=RANGE)
assert r.json()["series"] is None


def test_llm_cost_bucket_day_series(seeded):
r = client.get(f"{BASE}/llm/cost", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
series = r.json()["series"]
assert [p["date"] for p in series] == ["2026-07-10", "2026-07-12", "2026-07-15"]
assert [p["calls"] for p in series] == [1, 1, 1]
assert [p["total_tokens"] for p in series] == [150, 300, 120]
assert series[0]["cost_usd"] == pytest.approx(0.01)
assert series[1]["cost_usd"] == pytest.approx(0.05)
assert series[2]["cost_usd"] == pytest.approx(0.02)


def test_errors_bucket_day_series(seeded):
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["series"] == [{"date": "2026-07-15", "count": 1}]
assert body["total"] == 1
assert body["truncated"] is False


def test_bucket_rejects_invalid_value(seeded):
r = client.get(f"{BASE}/usage/summary", params={**RANGE, "bucket": "hour"})
assert r.status_code == 422


def test_errors_bucket_series_truncation_is_surfaced(monkeypatch):
# The errors series needs its own range scan (the feed itself is paginated
# server-side); that scan can hit the cap and must say so, never silently.
store = {
"events": [
{"event_type": "error.5xx", "category": "error", "user_id": "u1", "request_id": f"r{i}",
"payload": {"path": "/api/x", "method": "GET", "status_code": 500, "duration_ms": 1.0},
"created_at": ts}
for i, ts in enumerate([IN1, IN2, IN3])
],
"llm_usage": [],
}
monkeypatch.setattr(analytics, "table", lambda name: _FakeTable(store.get(name, [])))
monkeypatch.setattr(analytics, "_PAGE", 1)
monkeypatch.setattr(analytics, "_SCAN_CAP", 2)
r = client.get(f"{BASE}/errors", params={**RANGE, "bucket": "day"})
assert r.status_code == 200
body = r.json()
assert body["truncated"] is True
assert [p["count"] for p in body["series"]] == [1, 1] # capped at 2 scanned rows

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the shared mocked-Supabase fixture.

These new cases depend on the local seeded fixture, which directly monkeypatches analytics.table (Lines 102-104). Move the setup to the shared tests/conftest.py fixture so route mocks remain consistent across backend tests.

As per coding guidelines, backend tests must use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_admin_analytics_routes.py` around lines 270 - 339, Move
the mocked Supabase setup currently owned by the local seeded fixture into the
shared seeded fixture in tests/conftest.py, including the analytics.table patch
and common seed data. Remove the duplicate local setup from these analytics
route tests so all cases use the shared mocked Supabase and Gemini fixtures
consistently.

Source: Coding guidelines

Comment on lines +58 to +74
const load = React.useCallback(async () => {
setLoading(true);
try {
setData(await fetcher());
setError(null);
} catch (err) {
setError(humanizeError(err, "Couldn't load analytics."));
} finally {
setLoading(false);
}
}, [fetcher]);

React.useEffect(() => {
load();
}, [load]);

return { data, loading, error, reload: load };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Prevent stale requests from overwriting the selected range.

Line 61 accepts every completion. After a range or group change, an older request can resolve last and replace the new result; if the newer request fails, retained old data also hides the retry banner. Clear stale data and guard every state update with a monotonically increasing request id.

Suggested fix
 function useAnalyticsQuery<T>(fetcher: () => Promise<T>): AnalyticsQuery<T> {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
+ const requestId = React.useRef(0);
const load = React.useCallback(async () => {
+ const id = ++requestId.current;
setLoading(true);
+ setData(null);+ setError(null);
try {
- setData(await fetcher());- setError(null);+ const result = await fetcher();+ if (id === requestId.current) setData(result);
} catch (err) {
- setError(humanizeError(err, "Couldn't load analytics."));+ if (id === requestId.current) {+ setError(humanizeError(err, "Couldn't load analytics."));+ }
} finally {
- setLoading(false);+ if (id === requestId.current) setLoading(false);
}
}, [fetcher]);
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constrequestId=React.useRef(0);
constload=React.useCallback(async()=>{
constid=++requestId.current;
setLoading(true);
setData(null);
setError(null);
try{
constresult=awaitfetcher();
if(id===requestId.current)setData(result);
}catch(err){
if(id===requestId.current){
setError(humanizeError(err,"Couldn't load analytics."));
}
}finally{
if(id===requestId.current)setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/lib/useAdminAnalytics.ts` around lines 58 - 74, Update the load
callback to assign each invocation a monotonically increasing request id, clear
existing data when starting a new request, and guard setData, setError, and
setLoading updates so only the latest request can change state. Ensure an older
completion cannot overwrite the selected range and that a failed latest request
does not retain stale data.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Background-reload failures are silently swallowed — useAnalyticsQuery keeps the previous data on a failed reload, and Panel gates its banner on error && !data, so after a successful first load a failed range-preset/group-by refetch leaves stale numbers on screen with no toast or indicator. The house convention from the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463 Calendar fix is: initial failure → banner, background-reload failure → toast keeping the loaded view (see the hasLoadedRef pattern in Calendar.tsx).

functionuseAnalyticsQuery<T>(fetcher: ()=>Promise<T>): AnalyticsQuery<T>{
const[data,setData]=React.useState<T|null>(null);
const[loading,setLoading]=React.useState(true);
const[error,setError]=React.useState<string|null>(null);
constload=React.useCallback(async()=>{
setLoading(true);
try{
setData(awaitfetcher());
setError(null);
}catch(err){
setError(humanizeError(err,"Couldn't load analytics."));
}finally{
setLoading(false);
}
},[fetcher]);
React.useEffect(()=>{
load();
},[load]);
return{ data, loading, error,reload: load};
}

<sectionclassName="card"style={{padding: "var(--pad-lg)"}}>
<h2className="h-serif"style={{fontSize: 18,marginBottom: 12}}>{title}</h2>
{query.error&&!query.data ? (
<divrole="alert"style={{display: "flex",alignItems: "center",gap: 12,color: "var(--text-muted)",fontSize: 13}}>
<span>{query.error}</span>
<buttondata-testid={`${testid}-retry`}className="btn btn--sm"onClick={query.reload}>
Try again
</button>
</div>
) : !query.data ? (
<divstyle={{color: "var(--text-muted)",fontSize: 13}}>Loading…</div>
) : (
children(query.data)
)}
</section>

Also fixing below the posting bar (scored <80): a stale-response race in the same hook (no out-of-order guard on rapid range/group-by changes), missing client-side from <= to clamping on the custom date inputs (backend 422s the inverted range), and the admin-analytics-costgroup-* testids which break the docs' kebab convention (→ admin-analytics-cost-group-*).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

… guard, range clamp, testid rename
- useAnalyticsQuery: monotonic request sequence so an out-of-order response
can't overwrite newer data; background-reload failures keep the loaded
view and toast (#463 Calendar convention) instead of vanishing.
- Custom range inputs clamp the other edge (backend 422s from > to) and
carry min/max bounds.
- admin-analytics-costgroup-* -> admin-analytics-cost-group-* per the
testids kebab convention (code + doc inventory).
- TruncatedBadge copy made meaning-neutral for the errors panel's
series-only truncation (#122 note).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230