Skip to content

feat(admin): MAU dashboard + metrics audit + dashboard improvements - #1636

Merged
2witstudios merged 8 commits into
masterfrom
pu/admin-mau
Jun 12, 2026
Merged

2witstudios merged 8 commits into
masterfrom
pu/admin-mau

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Fix broken metrics signal: userActivities was never written in normal app use — all active-user queries now use sessions.lastUsedAt (current snapshot) and activityLogs (trends/heatmap/feature-usage)
  • New Growth page (/growth): MAU/WAU/DAU stickiness cards, 12-month MAU trend chart (recharts AreaChart), tier breakdown pie, daily activity bar, rolling 30d signup growth stat
  • Unit Economics: margin-by-tier table (free/pro/founder/business breakdown)
  • Users page: last-active column sourced from sessions, dormant user filter toggle, dormant count in summary row
  • Support page: registered-user badge (with tier) matched by email on each submission, open/closed status toggle — new resolvedAt column, PATCH endpoint, and all/open/closed filter
  • AI Billing: margin trend line chart (charged vs real cost vs margin per day or month using recharts)
  • Nav alert dots: lightweight /api/admin/alerts endpoint (60s cache); red dot on Monitoring (error rate > 5%), AI Billing + Unit Economics (negative-margin accounts or live holds > 50)

Post-review fixes (Codex P1/P2)

  • MAU/WAU/DAU accuracy (P1): Removed isNull(revokedAt) filter from window queries — users who were active during the period but later logged out still count. The filter is correct only for "currently online" metrics.
  • Map date-key bug (P2): DATE_TRUNC returns Date objects from node-postgres despite sql<string> annotation. Added toKey() normalisation to ISO strings before constructing/reading Maps, fixing the signup-trend join silently returning zero.
  • MoM growth bias (P2): Switched from calendar-month boundaries to rolling 30d vs prior 30d comparison, eliminating systematic negative-growth before month-end. Updated stat card labels accordingly.
  • Route handler pattern: contact/[id]/route.ts now uses the withAdminAuth<RouteContext> generic overload (matching gift-subscription pattern) instead of an unsafe type cast.
  • alerts/route.ts: Fixed to call getApiMetrics (not getSystemHealth) for error rate — errorRate is only computed in getApiMetrics.
  • ESLint: Removed unused userActivities import and stale startOfThisMonth/startOfLastMonth vars (leftover after rolling-window refactor).

Post-review fixes (CodeRabbit)

  • ai-billing chart order: marginByPeriod now sorted ascending before LineChart — was rendering newest-first making the trend slope look backwards.
  • growth page res.ok: Added if (!res.ok) throw before res.json() — a non-2xx response was clobbering the stale cache with undefined.
  • AdminLayoutClient type safety: Added isAlertState() runtime type-guard before setAlerts.
  • CSV serializers sync: Added marginByPeriod rows to ai-billing CSV and byTier rows to unit-economics CSV — both were JSON-only.
  • Users last-active: MAX(COALESCE(lastUsedAt, createdAt)) across ALL sessions (removed isNull(revokedAt) filter) — logged-out users now show their last active date.
  • ContactSubmissionsTable error feedback: PATCH failures surface an inline error message instead of silently re-enabling the toggle.
  • Fixed 12-month MAU trend: twelveMonthsAgo was getFullYear()-1, getMonth() (13 months); changed to getMonth()-11 (exactly 12). Both mauTrend and dauTrend now use fixed 12-month/30-day UTC bucket arrays so months/days with zero activity but non-zero signups are included.

DB migration

0161_contact_resolved_at.sql — adds nullable resolvedAt timestamp to contact_submissions.

Test plan

  • /growth page loads, charts render, stat cards show MAU/WAU/DAU
  • Users page shows last active dates and dormant filter works
  • Unit Economics shows tier breakdown table
  • Support page shows registered-user badge on known emails; resolve toggle persists on reload (via PATCH)
  • AI Billing shows margin trend chart when data exists
  • Nav dots appear/disappear correctly based on alert thresholds
  • Run bun run db:migrate before testing support resolve feature

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR extends the admin dashboard with four major analytics features: a new Growth page tracking MAU/DAU trends and tier distribution, user dormancy metrics based on session activity, a contact-submission resolution workflow, and margin breakdown analytics by tier and billing period. The changes refactor monitoring queries from userActivities to activityLogs and sessions, add database support for tracking contact submission resolution, and introduce navigation badges for health alerts.

Changes

Admin Dashboard Extensions

Layer / File(s) Summary
Growth Metrics Infrastructure
apps/admin/src/lib/monitoring-queries.ts, apps/admin/src/lib/monitoring-types.ts, apps/admin/src/app/api/monitoring/[metric]/route.ts
New getGrowthMetrics() query computes MAU/DAU trends, new-user signups, and tier distribution from sessions and activityLogs; defines GrowthMetrics, GrowthSummary, MAUTrendRow, DAUTrendRow, and TierBreakdownRow types; extends monitoring API to serve growth endpoint.
Growth Dashboard Page
apps/admin/src/app/(admin)/growth/page.tsx
New Growth page fetches growth metrics, renders hero/secondary stat cards, MAU trend area chart, tier breakdown pie chart, daily activity bar chart, and monthly summary table with growth percentages.
User Activity & Dormancy Tracking
apps/admin/src/lib/monitoring-queries.ts, apps/admin/src/app/api/admin/users/route.ts
Refactors getSystemHealth and getUserActivity to use sessions and activityLogs instead of userActivities; users endpoint now computes and returns lastActiveAt per user from MAX(sessions.lastUsedAt) excluding revoked sessions.
User Dormancy UI & Filtering
apps/admin/src/app/(admin)/users/page.tsx, apps/admin/src/components/admin/UsersTable.tsx
Users page displays dormant-user count (30d+ threshold) as new stat card; UsersTable adds lastActiveAt field, dormancy-status badge, and toggle filter to show dormant users only.
Contact Submission Resolution API & Schema
packages/db/drizzle/0161_contact_resolved_at.sql, packages/db/src/schema/contact.ts, apps/admin/src/app/api/admin/contact/[id]/route.ts, apps/admin/src/app/api/admin/contact/route.ts
Database migration adds resolvedAt timestamp column to contact_submissions; new PATCH endpoint updates resolution status; GET endpoint now supports status filtering (open/closed/all) and left-joins users to include subscription-tier info.
Contact Submission UI: Status Filtering & Resolution
apps/admin/src/app/(admin)/support/page.tsx, apps/admin/src/components/admin/ContactSubmissionsTable.tsx
Support page adds statusFilter state; ContactSubmissionsTable renders status filter buttons, per-row resolved/open toggle backed by PATCH requests, subscription-tier badge, and account details in expanded view.
Margin Analytics by Tier & Period
apps/admin/src/lib/monitoring-queries.ts, apps/admin/src/app/(admin)/unit-economics/page.tsx, apps/admin/src/app/(admin)/ai-billing/page.tsx, apps/admin/src/app/api/admin/ai-billing/route.ts, apps/admin/src/app/api/admin/unit-economics/route.ts
New getMarginByTier() aggregates margin metrics by subscription tier; unit-economics page displays tier margin table; AI billing page shows margin-trend chart, and API endpoints fetch margin data.
Admin Layout Navigation & Alert Indicators
apps/admin/src/app/api/admin/alerts/route.ts, apps/admin/src/app/AdminLayoutClient.tsx, apps/admin/src/app/(admin)/dashboard/page.tsx
New lightweight alerts API endpoint fetches monitoring health metrics and returns boolean flags with CDN caching; AdminLayoutClient fetches alerts and renders red-dot indicators on dashboard tabs; dashboard navigation adds Growth section.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

A growth dashboard blooms on the admin stage,
with MAU trends charted on every page.
Dormant users awakened, their slumber revealed,
contact resolutions and margin yields sealed,
while alerts dance red on the navigation's height—
metrics by tier and period burning bright. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% 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 name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: introducing a new Growth/MAU dashboard page, updating metrics queries to use better data sources, and adding related dashboard improvements across multiple admin pages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/admin-mau

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 and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a30d712ac0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/admin/src/lib/monitoring-queries.ts Outdated
Comment thread apps/admin/src/lib/monitoring-queries.ts Outdated
Comment thread apps/admin/src/lib/monitoring-queries.ts
2witstudios and others added 3 commits June 11, 2026 23:00
- Fix broken userActivities signal: switch active-user count to
  sessions.lastUsedAt and all trend/heatmap queries to activityLogs
- Add /growth page: MAU/WAU/DAU stickiness, 12-month trend, tier pie,
  daily activity bar chart, MoM growth card
- Add getGrowthMetrics() query (MAU/WAU/DAU from sessions, trends from
  activityLogs, signups from users.createdAt)
- Users page: last-active column from sessions + dormant filter toggle
- Unit Economics: add margin-by-tier table; wire getMarginByTier()
- Support: link submissions to registered user accounts by email match,
  add open/closed status toggle (resolvedAt column + PATCH endpoint)
- AI Billing: add margin trend line chart (real cost vs charged per day)
- Nav: fetch /api/admin/alerts on mount; show red dot on Monitoring tab
  when error rate > 5%, on AI Billing/Unit Economics when negative-margin
  accounts exist or live holds > 50

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ATCH

- alerts/route.ts: use getApiMetrics (not getSystemHealth) for error rate —
  errorRate is computed in getApiMetrics, getSystemHealth only returns log levels
- contact/[id]/route.ts: use explicit RouteContext generic for withAdminAuth
  instead of unsafe type cast, matching convention in gift-subscription route
- journal: correct migration timestamp to present day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
P1 — MAU/WAU/DAU: drop isNull(revokedAt) filter so users who were active
in the window but later logged out still count; revokedAt is current session
state, not a historical activity gate.

P2 — MoM growth: switch from partial-calendar-month vs full-last-month
to rolling 30d vs prior 30d, eliminating the systematic negative-growth
bias before month-end.

P2 — Map date keys: DATE_TRUNC returns Date objects from node-postgres
despite sql<string> annotation; normalise via toISOString() before using
as Map keys so signup-trend lookups don't silently return zero.

Update growth page stat labels to reflect rolling-window semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2witstudios and others added 4 commits June 11, 2026 23:05
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…schema import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eration

db:generate in CI reads the latest snapshot and diffs against the current
schema. Without 0161_snapshot.json, it would regenerate the resolvedAt
column migration causing 'column already exists' on db:migrate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (1)
apps/admin/src/app/api/admin/contact/[id]/route.ts (1)

12-12: 💤 Low value

Consider validating the request body.

The body is cast without validation. If resolved is missing or has an unexpected type, the behavior may be surprising (e.g., undefined is falsy → sets resolvedAt to null). For an admin-only endpoint this is low risk, but explicit validation would be cleaner.

♻️ Optional validation
-    const body = await request.json() as { resolved: boolean };
+    const body = await request.json() as { resolved?: unknown };
+    if (typeof body.resolved !== 'boolean') {
+      return Response.json({ error: 'Invalid request: resolved must be a boolean' }, { status: 400 });
+    }
🤖 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 `@apps/admin/src/app/api/admin/contact/`[id]/route.ts at line 12, The handler
currently casts request.json() to { resolved: boolean } without checking; update
the route.ts request handler to validate that the parsed body has a boolean
resolved property (e.g., check typeof body?.resolved === 'boolean'), and if not
return a 400/throw a BadRequest response; then use the validated boolean to
decide resolvedAt (set to new Date() when true, null when false) and proceed to
updateContact (keep function name updateContact) so behavior is explicit and
safe.
🤖 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 `@apps/admin/src/app/`(admin)/ai-billing/page.tsx:
- Around line 301-306: The data passed to LineChart is in newest-first order
(data.marginByPeriod from getMarginByPeriod()), so reverse or sort that array
into chronological order before mapping for the chart; locate the LineChart
usage in page.tsx and change the mapped source from data.marginByPeriod to a
chronologically ordered array (e.g., data.marginByPeriod.slice().reverse() or
sort by r.period) so the series (realCost/charged/margin) are rendered
left-to-right in time.

In `@apps/admin/src/app/`(admin)/growth/page.tsx:
- Around line 98-108: The fetchData function currently calls fetchWithAuth and
unconditionally does setData(json.data) which can overwrite cached data when the
Response is non-OK; update fetchData to check res.ok after awaiting
fetchWithAuth and only call setData(json.data) when res.ok is true, otherwise
preserve the existing data (and optionally read/log the error payload from
res.json or handle it via your error UI); ensure setIsLoading still runs in
finally and do not swallow the non-OK response path so stale dashboard data
remains visible.

In `@apps/admin/src/app/AdminLayoutClient.tsx`:
- Around line 40-44: The code calls fetchWithAuth('/api/admin/alerts') and
passes the untyped r.json() result straight into setAlerts (inside useEffect),
bypassing the expected AlertState shape; update the fetch chain to
narrow/validate the parsed JSON before calling setAlerts: after r.json() run a
runtime validator or mapping to pick/convert only the expected fields into
AlertState (e.g., validate array, required keys, types, and map to the
AlertState shape), and only call setAlerts(validatedValue) when the shape checks
pass (otherwise ignore/log); reference useEffect, fetchWithAuth, setAlerts and
the AlertState shape when adding the validation step.

In `@apps/admin/src/app/api/admin/ai-billing/route.ts`:
- Around line 16-17: The CSV branches in the AI billing and unit economics
routes are still emitting the old schema; update the CSV serialization logic in
the format=csv branches of the handlers that use marginByPeriod and byTier so
they emit the same fields and derived metrics as the JSON response (e.g.,
include the new metrics produced by getMarginByPeriod and the new tier
aggregates). Locate the format=csv code paths in the route handlers that
reference marginByPeriod and byTier, align column headers and row generation
with the JSON payload shape, and ensure any derived calculations (totals,
percentages) match the JSON-side logic; consider extracting a shared serializer
function to avoid future drift between JSON and CSV outputs.

In `@apps/admin/src/app/api/admin/users/route.ts`:
- Around line 85-92: The aggregation currently uses MAX(${sessions.lastUsedAt})
which yields null when lastUsedAt is unset; update the aggregation in the
db.select for lastActiveAt to use MAX(COALESCE(${sessions.lastUsedAt},
${sessions.createdAt})) so createdAt is used as a fallback. Locate the select
that builds lastActiveAt (db.select... { userId: sessions.userId, lastActiveAt:
sql<Date>`MAX(${sessions.lastUsedAt})` }) and replace the expression with
MAX(COALESCE(...)) referencing sessions.lastUsedAt and sessions.createdAt to
ensure users with no lastUsedAt still show a recent activity timestamp.

In `@apps/admin/src/components/admin/ContactSubmissionsTable.tsx`:
- Around line 130-147: The toggle currently silently fails when the PATCH
returns a non-2xx: update handleToggleResolved to capture and surface errors by
adding a resolve error state (e.g., resolveError via useState) and setting it
when response.ok is false or fetch throws; keep existing setResolvingId and
setLocalResolved logic but on failure set resolveError and leave localResolved
unchanged, and trigger a user-visible notification (toast or inline alert) in
the component UI that reads resolveError so users see the failure; ensure the
finally block still clears setResolvingId to re-enable the button.

In `@apps/admin/src/lib/monitoring-queries.ts`:
- Around line 1207-1318: mauTrend and dauTrend must use fixed calendar buckets
instead of only mapping grouped rows: generate an explicit 12-month array of
month-start keys and a 30-day array of day-start keys (using the same toKey
normaliser) and then populate each bucket from mauTrendRaw/signupsByMonthRaw and
dauTrendRaw/signupsByDayRaw respectively so months/days with zero activity but
non-zero signups are included and you only produce exactly 12 months and 30
days; update the logic that computes the month-window (currently
twelveMonthsAgo) so the bucket start is the correct 12-month range (create the
first month by new Date(now.getFullYear(), now.getMonth(), 1) and iterate
backwards/forwards to produce 12 month boundaries), and fill mauTrend and
dauTrend from those generated buckets using mauTrendRaw/dauTrendRaw and signups
maps (refer to mauTrendRaw, signupsByMonthRaw, mauTrend, dauTrendRaw,
signupsByDayRaw, toKey).

---

Nitpick comments:
In `@apps/admin/src/app/api/admin/contact/`[id]/route.ts:
- Line 12: The handler currently casts request.json() to { resolved: boolean }
without checking; update the route.ts request handler to validate that the
parsed body has a boolean resolved property (e.g., check typeof body?.resolved
=== 'boolean'), and if not return a 400/throw a BadRequest response; then use
the validated boolean to decide resolvedAt (set to new Date() when true, null
when false) and proceed to updateContact (keep function name updateContact) so
behavior is explicit and safe.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c6b55bb-6f27-4407-93c6-478042db7767

📥 Commits

Reviewing files that changed from the base of the PR and between 885a54f and 51a9a59.

📒 Files selected for processing (22)
  • apps/admin/src/app/(admin)/ai-billing/page.tsx
  • apps/admin/src/app/(admin)/dashboard/page.tsx
  • apps/admin/src/app/(admin)/growth/page.tsx
  • apps/admin/src/app/(admin)/support/page.tsx
  • apps/admin/src/app/(admin)/unit-economics/page.tsx
  • apps/admin/src/app/(admin)/users/page.tsx
  • apps/admin/src/app/AdminLayoutClient.tsx
  • apps/admin/src/app/api/admin/ai-billing/route.ts
  • apps/admin/src/app/api/admin/alerts/route.ts
  • apps/admin/src/app/api/admin/contact/[id]/route.ts
  • apps/admin/src/app/api/admin/contact/route.ts
  • apps/admin/src/app/api/admin/unit-economics/route.ts
  • apps/admin/src/app/api/admin/users/route.ts
  • apps/admin/src/app/api/monitoring/[metric]/route.ts
  • apps/admin/src/components/admin/ContactSubmissionsTable.tsx
  • apps/admin/src/components/admin/UsersTable.tsx
  • apps/admin/src/lib/monitoring-queries.ts
  • apps/admin/src/lib/monitoring-types.ts
  • packages/db/drizzle/0161_contact_resolved_at.sql
  • packages/db/drizzle/meta/0161_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/contact.ts

Comment thread apps/admin/src/app/(admin)/ai-billing/page.tsx Outdated
Comment thread apps/admin/src/app/(admin)/growth/page.tsx
Comment thread apps/admin/src/app/AdminLayoutClient.tsx
Comment thread apps/admin/src/app/api/admin/ai-billing/route.ts
Comment thread apps/admin/src/app/api/admin/users/route.ts Outdated
Comment thread apps/admin/src/components/admin/ContactSubmissionsTable.tsx
Comment thread apps/admin/src/lib/monitoring-queries.ts Outdated
- Sort marginByPeriod ascending before LineChart render (was newest-first)
- Check res.ok in growth page fetch before setting stale data to undefined
- Type-guard alerts JSON in AdminLayoutClient before setAlerts (was untyped any)
- Add marginByPeriod rows to ai-billing CSV export (was JSON-only)
- Add byTier rows to unit-economics CSV export (was JSON-only)
- COALESCE(lastUsedAt, createdAt) in users last-active query; remove
  revokedAt filter so logged-out users still show their last active date
- Add error feedback in ContactSubmissionsTable PATCH (was silent failure)
- Fix 13-month MAU trend (twelveMonthsAgo now 11 months back, not 12)
- Generate fixed 12-month and 30-day calendar buckets for growth charts
  so months/days with zero activity but non-zero signups still appear

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Addressed all CodeRabbit review comments in commit d03018b:

  • ai-billing chart order: marginByPeriod now sorted ascending before passing to LineChart — trend slope was displaying reversed (newest-to-oldest).
  • growth page res.ok: Added if (!res.ok) throw new Error(...) before res.json() — a 4xx/5xx response was clobbering the cached payload with undefined.
  • AdminLayoutClient type safety: Added isAlertState() runtime type-guard; setAlerts is only called when the parsed JSON has all three expected boolean fields.
  • CSV serializers sync: Added marginByPeriod rows to the ai-billing CSV export and byTier rows to the unit-economics CSV export — both were JSON-only.
  • Users last-active: Changed query to MAX(COALESCE(lastUsedAt, createdAt)) across ALL sessions (removed isNull(revokedAt) filter) — users who logged out still show their last active date instead of null/"Never".
  • ContactSubmissionsTable error feedback: PATCH failures now surface an inline error message instead of silently re-enabling the toggle.
  • Growth calendar buckets: Fixed 13-month MAU trend (was getFullYear()-1, getMonth() → now getMonth()-11). Both mauTrend and dauTrend now use fixed 12-month / 30-day UTC bucket arrays populated from Maps, so months/days with zero activity but non-zero signups are included and counts are always correct.

@2witstudios
2witstudios merged commit 8629d8a into master Jun 12, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/admin-mau branch June 12, 2026 23:41
Sign up for free to 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