Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ This file records user-visible and operational changes that reach `main`. Pull r

## Unreleased

- Stop sending usage events to PostHog now that growth is counted from Convex. Removed: the `overlay.metrics.*` client and server metrics (BFF requests, model tokens, cache, chat-open latency, duplicate requests, run recovery, session refresh), `retention.engaged`, `auth.session.signed_in`, chat, Work, integration, auth-callback and sign-out events, and the billing, automation and workspace lifecycle mappings. PostHog keeps pageviews, identify/reset, feature flags and one signup event, renamed from `activation.completed` to `user.signed_up`. The unused Convex `withMetrics` wrapper is gone; its table stays until the cleanup cron empties it.

- Add a Growth section to the admin console for admin, auditor and billing operators: weekly active users, new users, messages and paid subscriptions against the prior week; weekly trends for what was created, work done and usage; W1/W4 retention by signup week; and the top workspaces by spend. It reads only the nightly growth rollups.

- Add nightly growth rollups in Convex: daily counts of what was created and done, active users, W1/W4 retention cohorts, paid subscriptions, top-ups and billed usage, with a one-time backfill. Each file text change is now recorded per day with every person who made it.

- Switch existing accounts to double sidebar once across all workspaces, while allowing subsequent single-sidebar opt-outs.

- Keep the gray gutters equal on both sides of onboarding while its navigation is hidden.
Expand Down
16 changes: 16 additions & 0 deletions apps/admin/src/app/api/v1/admin/growth/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'

import { AdministrativeAuthorizationError } from '@/server/admin/AdministrativeService'
import { adminConvexQuery } from '@/server/convex-admin'
import { getAdminServerContext } from '@/server/context'
import { adminRoute } from '@/server/guard'
import type { AdminGrowthSummary } from '@/shared/admin-types'

export const GET = adminRoute(async (_request, context) => {
const server = getAdminServerContext()
if (!await server.administrativeService.canViewGrowth(context.operator.user.id)) {
throw new AdministrativeAuthorizationError()
}
const growth = await adminConvexQuery<AdminGrowthSummary>('admin/console:getGrowthSummary')
return NextResponse.json({ growth })
}, { auditAction: 'administration.growth.read', resourceType: 'growth_summary' })
22 changes: 18 additions & 4 deletions apps/admin/src/features/admin/AdminConsolePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import {
RefreshCw,
Search,
ShieldCheck,
TrendingUp,
UsersRound,
type LucideIcon,
} from 'lucide-react'
import { AppScreenBody, AppScreenHeader, AppScreenShell } from '@overlay/modules-react/shell'
import { AuthorizationAdminPanel } from '@/features/admin/authorization/AuthorizationAdminPanel'
import { CatalogPolicyAdminPanel } from '@/features/admin/catalog/CatalogPolicyAdminPanel'
import { GrowthPanel } from '@/features/admin/growth/GrowthPanel'
import { PanelHeader } from './PanelHeader'

type UsageRow = {
Expand All @@ -31,7 +33,7 @@ type AuditRow = {
createdAt: number
}

type AdminSection = 'overview' | 'roles' | 'groups' | 'catalog'
type AdminSection = 'overview' | 'growth' | 'roles' | 'groups' | 'catalog'

type AdminSectionOption = {
value: AdminSection
Expand All @@ -42,6 +44,7 @@ type AdminSectionOption = {
type AdminPermissions = {
canViewUsage: boolean
canViewAudit: boolean
canViewGrowth: boolean
canViewRoles: boolean
canManageRoles: boolean
canViewGroups: boolean
Expand All @@ -53,13 +56,15 @@ type AdminPermissions = {
function useAdminSections({
canViewUsage,
canViewAudit,
canViewGrowth,
canViewRoles,
canViewGroups,
canViewCatalog,
}: AdminPermissions) {
const [section, setSection] = useState<AdminSection>('overview')
const sections: AdminSectionOption[] = [
...(canViewUsage || canViewAudit ? [{ value: 'overview' as const, label: 'Overview', Icon: LayoutDashboard }] : []),
...(canViewGrowth ? [{ value: 'growth' as const, label: 'Growth', Icon: TrendingUp }] : []),
...(canViewRoles ? [{ value: 'roles' as const, label: 'Roles', Icon: ShieldCheck }] : []),
...(canViewGroups ? [{ value: 'groups' as const, label: 'Groups', Icon: UsersRound }] : []),
...(canViewCatalog ? [{ value: 'catalog' as const, label: 'Catalog', Icon: ListTree }] : []),
Expand Down Expand Up @@ -255,14 +260,16 @@ export function AdminConsolePage({ capabilities }: { capabilities: string[] }) {
const permissions: AdminPermissions = {
canViewUsage: can('usage.read') || can('support.access'),
canViewAudit: can('audit.read'),
// Growth is open to admin, auditor and billing roles (the server checks the role itself).
canViewGrowth: can('audit.read') || can('usage.read'),
canViewRoles: can('roles.read'),
canManageRoles: can('roles.manage'),
canViewGroups: can('groups.read'),
canManageGroups: can('groups.manage'),
canViewCatalog: can('roles.read'),
canManageCatalog: can('roles.manage'),
}
const { canViewRoles, canManageRoles, canViewGroups, canManageGroups, canViewCatalog, canManageCatalog } = permissions
const { canViewGrowth, canViewRoles, canManageRoles, canViewGroups, canManageGroups, canViewCatalog, canManageCatalog } = permissions
const { section, setSection, sections } = useAdminSections(permissions)
const {
usage,
Expand All @@ -276,21 +283,24 @@ export function AdminConsolePage({ capabilities }: { capabilities: string[] }) {
setError,
load,
} = useAdminData(permissions)
const [growthReloadKey, setGrowthReloadKey] = useState(0)

useEffect(() => {
void load().catch((loadError) => setError(message(loadError)))
}, [load, setError])

useEffect(() => {
if (section === 'overview' && (permissions.canViewUsage || permissions.canViewAudit)) return
if (section === 'growth' && canViewGrowth) return
if (section === 'roles' && canViewRoles) return
if (section === 'groups' && canViewGroups) return
if (section === 'catalog' && canViewCatalog) return
if (permissions.canViewUsage || permissions.canViewAudit) setSection('overview')
else if (canViewGrowth) setSection('growth')
else if (canViewRoles) setSection('roles')
else if (canViewGroups) setSection('groups')
else if (canViewCatalog) setSection('catalog')
}, [canViewCatalog, canViewGroups, canViewRoles, permissions.canViewAudit, permissions.canViewUsage, section, setSection])
}, [canViewCatalog, canViewGroups, canViewGrowth, canViewRoles, permissions.canViewAudit, permissions.canViewUsage, section, setSection])

return (
<AppScreenShell
Expand All @@ -308,7 +318,10 @@ export function AdminConsolePage({ capabilities }: { capabilities: string[] }) {
type="button"
aria-label="Refresh administration"
className="inline-flex size-8 items-center justify-center rounded-md text-(--muted) transition-colors hover:bg-(--surface-subtle) hover:text-(--foreground)"
onClick={() => void load()}
onClick={() => {
void load()
setGrowthReloadKey((key) => key + 1)
}}
>
<RefreshCw size={15} />
</button>
Expand All @@ -330,6 +343,7 @@ export function AdminConsolePage({ capabilities }: { capabilities: string[] }) {
<p className='mb-5 text-sm text-red-600 dark:text-red-400'>{error}</p>
) : null}

{section === 'growth' && canViewGrowth ? <GrowthPanel reloadKey={growthReloadKey} /> : null}
{section === 'roles' && canViewRoles ? (
<AuthorizationAdminPanel
canManage={canManageRoles}
Expand Down
207 changes: 207 additions & 0 deletions apps/admin/src/features/admin/growth/GrowthPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
'use client'

import { useEffect, useState } from 'react'
import { TrendingUp } from 'lucide-react'
import { EmptyState } from '@overlay/ui/primitives'

import { adminApi } from '@/shared/admin-api'
import type { AdminGrowthSummary } from '@/shared/admin-types'
import {
change,
formatValue,
growthGroups,
growthWeeks,
headline,
retentionRate,
shortDay,
type GrowthMetric,
} from './growth-view'

export function GrowthPanel({ reloadKey }: { reloadKey: number }) {
const [summary, setSummary] = useState<AdminGrowthSummary | null>(null)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
const controller = new AbortController()
adminApi.admin.getGrowth({ signal: controller.signal })
.then((growth) => { setSummary(growth); setError(null) })
.catch((loadError: unknown) => {
if (!controller.signal.aborted) setError(loadError instanceof Error ? loadError.message : 'Failed to load growth')
})
return () => controller.abort()
}, [reloadKey])

if (error) return <p className="text-sm text-red-600 dark:text-red-400">{error}</p>
if (!summary) return <p className="py-12 text-sm text-(--muted)">Loading…</p>
return <GrowthView summary={summary} />
}

export function GrowthView({ summary }: { summary: AdminGrowthSummary }) {
if (!summary.lastCompletedDay || summary.days.length === 0) {
return (
<EmptyState
className="py-16"
icon={<TrendingUp size={22} strokeWidth={1.5} />}
title="No growth data yet"
description="The nightly count fills this in after its first run."
/>
)
}

const weeks = growthWeeks(summary.days)
const groups = growthGroups(weeks)

return (
<div data-testid="admin-growth" className="space-y-10">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-semibold">Growth</h2>
<p className="text-xs text-(--muted)">
Last 7 days to {shortDay(summary.lastCompletedDay)} (UTC)
{summary.computedAt ? ` · updated ${formatTime(summary.computedAt)}` : ''}
</p>
</div>

<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-(--border) bg-(--border) md:grid-cols-4">
{headline(weeks).map((metric) => (
<div key={metric.key} className="bg-(--background) p-4">
<p className="text-xs text-(--muted)">{metric.label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatValue(metric.values.at(-1) ?? null, metric.format)}</p>
<Change values={metric.values} />
</div>
))}
</div>

{groups.map((group) => (
<section key={group.title}>
<h3 className="text-xs font-medium uppercase tracking-wide text-(--muted)">{group.title}</h3>
<div className="mt-3 grid grid-cols-1 gap-x-10 border-t border-(--border) sm:grid-cols-2">
{group.metrics.map((metric) => <MetricRow key={metric.key} metric={metric} weekEnds={weeks.map((week) => week.endDay)} />)}
</div>
</section>
))}

<section>
<h3 className="text-xs font-medium uppercase tracking-wide text-(--muted)">Retention by signup week</h3>
{summary.cohorts.length === 0 ? (
<p className="mt-3 text-sm text-(--muted)">No signups counted yet.</p>
) : (
<table className="mt-3 w-full text-sm">
<thead>
<tr className="border-b border-(--border) text-left text-xs text-(--muted)">
<th className="py-2 font-normal">Week of</th>
<th className="py-2 text-right font-normal">Signups</th>
<th className="py-2 text-right font-normal" title="Active on days 7–13 after signup">W1</th>
<th className="py-2 text-right font-normal" title="Active on days 28–34 after signup">W4</th>
</tr>
</thead>
<tbody className="divide-y divide-(--border)">
{summary.cohorts.map((cohort) => (
<tr key={cohort.weekStart} className="tabular-nums">
<td className="py-2">{shortDay(cohort.weekStart)}</td>
<td className="py-2 text-right">{cohort.users.toLocaleString('en-US')}</td>
<td className="py-2 text-right">{retentionRate(cohort, 'w1')}</td>
<td className="py-2 text-right">{retentionRate(cohort, 'w4')}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="mt-2 text-xs text-(--muted)">A dash means the window hasn&apos;t closed for everyone in that week yet.</p>
</section>

<section>
<h3 className="text-xs font-medium uppercase tracking-wide text-(--muted)">Top workspaces by spend, last 30 days</h3>
{summary.topWorkspaces.length === 0 ? (
<p className="mt-3 text-sm text-(--muted)">No billed usage in the last 30 days.</p>
) : (
<div className="mt-3 divide-y divide-(--border) border-y border-(--border)">
{summary.topWorkspaces.map((workspace) => (
<div key={workspace.workspaceId} className="flex items-center justify-between gap-4 py-2.5 text-sm">
<div className="min-w-0">
<p className="truncate">{workspace.name ?? (workspace.kind === 'personal' ? 'Personal workspace' : 'Unknown workspace')}</p>
<p className="truncate text-xs text-(--muted)">{workspace.workspaceId}</p>
</div>
<div className="shrink-0 text-right tabular-nums">
<p>{formatValue(workspace.cents, 'cents')}</p>
{workspace.sandboxSeconds > 0 ? (
<p className="text-xs text-(--muted)">{formatHours(workspace.sandboxSeconds)} machine time</p>
) : null}
</div>
</div>
))}
</div>
)}
</section>
</div>
)
}

function MetricRow({ metric, weekEnds }: { metric: GrowthMetric; weekEnds: string[] }) {
return (
<div className="flex items-center gap-4 border-b border-(--border) py-3">
<div className="min-w-0 flex-1">
<p className="text-xs text-(--muted)">{metric.label}</p>
<p className="mt-0.5 flex items-baseline gap-2">
<span className="text-lg font-semibold tabular-nums">{formatValue(metric.values.at(-1) ?? null, metric.format)}</span>
<Change values={metric.values} inline />
</p>
{metric.detail ? <p className="text-xs text-(--muted)">{metric.detail}</p> : null}
</div>
<WeeklyBars metric={metric} weekEnds={weekEnds} />
</div>
)
}

/** One bar per week, newest on the right and darkest. */
function WeeklyBars({ metric, weekEnds }: { metric: GrowthMetric; weekEnds: string[] }) {
const max = Math.max(1, ...metric.values.map((value) => value ?? 0))
const width = 8
const gap = 3
const height = 32
return (
<svg
role="img"
aria-label={`${metric.label}, weekly`}
width={metric.values.length * (width + gap) - gap}
height={height}
className="shrink-0 text-(--foreground)"
>
{metric.values.map((value, index) => {
const barHeight = value ? Math.max(2, (value / max) * height) : 1
return (
<rect
key={weekEnds[index]}
x={index * (width + gap)}
y={height - barHeight}
width={width}
height={barHeight}
rx={1.5}
fill="currentColor"
opacity={index === metric.values.length - 1 ? 0.7 : 0.18}
>
<title>{`Week to ${shortDay(weekEnds[index])}: ${formatValue(value, metric.format)}`}</title>
</rect>
)
})}
</svg>
)
}

function Change({ values, inline = false }: { values: (number | null)[]; inline?: boolean }) {
const percent = change(values)
const text = percent === null ? 'no prior week' : `${percent > 0 ? '+' : ''}${percent}% vs prior week`
const tone = percent === null || percent === 0
? 'text-(--muted)'
: percent > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'
if (inline) return percent === null ? null : <span className={`text-xs ${tone}`}>{percent > 0 ? '+' : ''}{percent}%</span>
return <p className={`mt-1 text-xs ${tone}`}>{text}</p>
}

function formatHours(seconds: number): string {
const hours = seconds / 3600
return `${hours.toLocaleString('en-US', { maximumFractionDigits: hours < 10 ? 1 : 0 })} h`
}

function formatTime(ms: number): string {
return new Date(ms).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
49 changes: 49 additions & 0 deletions apps/admin/src/features/admin/growth/growth-view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import type { AdminGrowthDay } from '@/shared/admin-types'
import { change, formatValue, growthGroups, growthWeeks, retentionRate } from './growth-view'

function day(index: number, values: Partial<AdminGrowthDay> = {}): AdminGrowthDay {
const date = new Date(Date.UTC(2026, 2, 1 + index)).toISOString().slice(0, 10)
return {
day: date, usersCreated: 0, tasksCreated: 0, agentsCreated: 0, automationsCreated: 0, filesCreated: 0, filesEdited: 0,
threadsCreated: 0, messagesSent: 0, automationRuns: 0, automationRunsSucceeded: 0, automationRunsFailed: 0, topUps: 0,
topUpCents: 0, usageCents: 0, sandboxSeconds: 0, activeUsers: 0, weeklyActiveUsers: 0, monthlyActiveUsers: 0,
newPaidSubscriptions: 0, newPaidApproximate: 0, computedAt: 0, ...values,
}
}

test('weeks are complete seven-day windows ending on the last counted day', () => {
const days = Array.from({ length: 16 }, (_, index) => day(index, { messagesSent: 1, weeklyActiveUsers: index }))
const weeks = growthWeeks(days)
assert.deepEqual(weeks.map((week) => week.days.length), [2, 7, 7])
assert.equal(weeks.at(-1)!.endDay, days.at(-1)!.day)

const metrics = growthGroups(weeks).flatMap((group) => group.metrics)
assert.deepEqual(metrics.find((metric) => metric.key === 'messagesSent')!.values, [2, 7, 7])
// Weekly actives is already a rolling count, so it is read on the window's last day, not summed.
assert.deepEqual(metrics.find((metric) => metric.key === 'weeklyActiveUsers')!.values, [1, 8, 15])
})

test('paid subscriptions use the newest snapshot in the week and stay empty without one', () => {
const days = [
...Array.from({ length: 7 }, (_, index) => day(index)),
...Array.from({ length: 7 }, (_, index) => day(7 + index, index < 5 ? { paidSubscriptions: 3 + index } : {})),
]
const paid = growthGroups(growthWeeks(days)).flatMap((group) => group.metrics).find((metric) => metric.key === 'paidSubscriptions')!
assert.deepEqual(paid.values, [null, 7])
})

test('change, money and retention formatting', () => {
assert.equal(change([10, 15]), 50)
assert.equal(change([0, 4]), null)
assert.equal(change([null, 4]), null)
assert.equal(formatValue(12_345, 'cents'), '$123.45')
assert.equal(formatValue(40_120, 'cents'), '$401.20')
assert.equal(formatValue(182_450, 'cents'), '$1,825')
assert.equal(formatValue(null, 'count'), '—')
const cohort = { weekStart: '2026-03-02', users: 4, w1Active: 3, w4Active: 1, w1Final: true, w4Final: false }
assert.equal(retentionRate(cohort, 'w1'), '75%')
assert.equal(retentionRate(cohort, 'w4'), '—')
})
Loading
Loading