Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
480 changes: 480 additions & 0 deletions docs/superpowers/plans/2026-08-09-admin-registration-source-trend.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Admin Registration Source Trend

## Problem

The admin users dashboard currently spreads registration and invitation activity
across the Onboarding Trend and Invite Join Trend line charts. Those charts mix
different events and cohorts, so they do not answer the simpler question: how
many authentication accounts were created each day, and what profile state did
each new account reach?

Using only `public.users` would omit authentication accounts that never created a
profile. Treating every missing profile as a normal registration would also
claim more certainty than the data provides. The chart therefore needs an
`auth.users`-based population and three explicit, mutually exclusive profile
states.

## Design

Add a new **Registrations by source** chart to the admin users dashboard. It is a
vertical stacked-column chart with one column per day in the active admin date
range. Each column contains three datasets:

1. **Normal registration** — the authentication user has a matching
`public.users` row with `created_via_invite = false`.
2. **Organization invite** — the authentication user has a matching
`public.users` row with `created_via_invite = true`.
3. **Without profile** — the authentication user has no matching
`public.users` row.

Use blue for normal registrations, orange for organization invites, and neutral
gray for accounts without a profile. Tooltips show the selected segment's count
and percentage of that day's total. The legend keeps all three datasets visible
and uses the same order and colors throughout the date range.

The three datasets are exhaustive and do not overlap. Their sum for a day must
equal the number of `auth.users` rows created on that day.

## Data Contract

Extend the existing admin onboarding-funnel response with a
`registration_source_trend` array. Each item has this shape:

```ts
{
date: string
normal_registrations: number
invite_registrations: number
without_profile: number
}
```

Produce the array in `getAdminOnboardingFunnel` so the chart follows the existing
admin date filter and loading lifecycle. The query starts from `auth.users`,
left-joins `public.users` on the user ID, and groups by the authentication
account's `created_at::date`. It classifies each row using filtered counts:

- matching profile with `created_via_invite = false`;
- matching profile with `created_via_invite = true`;
- no matching profile.

Use the same inclusive start and exclusive end bounds as the existing admin
queries. Join the aggregated result to a generated date series so days with no
registrations are returned with zero values. No schema migration or new stored
function is required.

## Frontend

Add a focused reusable admin stacked-bar component rather than expanding
`AdminBarChart`, which is a horizontal single-dataset categorical chart. The new
component accepts daily labels and multiple named datasets, renders vertical
stacked bars, formats counts using the existing locale helpers, and supports the
dashboard's light and dark themes.

Place the chart in the onboarding analytics section near the existing
Onboarding Trend and Invite Join Trend charts. This is an additional diagnostic
view; it does not change or remove those charts or redefine their metrics.

Add translation keys to `messages/en.json` for the chart title, description,
and three dataset labels. Do not use inline translation fallbacks.

## Testing

Extend the admin statistics integration coverage with authentication users that
represent all three states on the same day:

- an auth user with a normal profile;
- an auth user with an invite-created profile;
- an auth user without a profile.

Assert that each user appears in exactly one dataset, that the daily sum equals
the number of new authentication users, that zero-count dates are retained, and
that start/end boundaries remain start-inclusive and end-exclusive. Update the
frontend types and add focused component coverage for dataset ordering, stacked
axes, colors, and tooltip percentage calculation.

Before handoff, run the focused backend and frontend tests, frontend lint, and
TypeScript checking.

## Scope

This change is read-only and limited to admin analytics. It does not alter the
registration flow, invitation flow, profile creation, existing onboarding
metrics, or organization membership behavior. The chart describes recorded
profile state; it does not infer that an auth-only account necessarily came from
normal registration.
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1739,16 +1739,21 @@
"onboarding-prerequisites-runtime": "Node.js 22+ or Bun installed",
"onboarding-prerequisites-title": "Prerequisites",
"onboarding-trend": "Onboarding Trend",
"normal-registration": "Normal registration",
"organization-invite": "Organization invite",
"org-joins-existing-account": "Org joins from existing accounts",
"org-joins-invite-register": "Org joins after invite register",
"invite-registrations": "Invite registrations",
"invite-join-trend-description": "Daily invite signups and org joins from new invite accounts versus existing accounts",
"invite-join-trend": "Invite Join Trend",
"registrations-by-source": "Registrations by source",
"registrations-by-source-description": "New authentication accounts grouped by their profile creation state",
"one-day-left": "One day left",
"one-hour-short": "1h",
"one-number": "1 number",
"one-special-character": "1 special character",
"one-uppercase": "1 uppercase",
"without-profile": "Without profile",
"optional": "Optional",
"open": "Open",
"open-channel": "Open channel",
Expand Down
72 changes: 72 additions & 0 deletions src/components/admin/AdminStackedBarChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { AdminStackedBarDataset } from '~/components/admin/adminStackedBarChart'
import { useDark } from '@vueuse/core'
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js'
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import { buildAdminStackedBarChartData, buildAdminStackedBarChartOptions } from '~/components/admin/adminStackedBarChart'
import { formatLocalDate } from '~/services/date'

interface DataSeries {
label: string
data: Array<{ date: string, value: number }>
color: string
}

const props = defineProps({
series: {
type: Array as () => DataSeries[],
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
})

const isDark = useDark()

Chart.register(
Tooltip,
BarController,
BarElement,
CategoryScale,
LinearScale,
Legend,
)

const chartData = computed(() => {
if (props.series.length === 0 || props.series[0].data.length === 0)
return buildAdminStackedBarChartData([], [])

const labels = props.series[0].data.map(item => formatLocalDate(item.date) || item.date)
const datasets: AdminStackedBarDataset[] = props.series.map(item => ({
label: item.label,
data: item.data.map(point => point.value),
color: item.color,
}))

return buildAdminStackedBarChartData(labels, datasets)
})

const chartOptions = computed(() => buildAdminStackedBarChartOptions(isDark.value))
</script>

<template>
<div class="relative w-full h-full overflow-hidden">
<div v-if="isLoading" class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg text-primary" />
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
</div>
<div v-else class="w-full h-full">
<Bar class="h-full w-full" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
115 changes: 115 additions & 0 deletions src/components/admin/adminStackedBarChart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { ChartData, ChartOptions } from 'chart.js'
import { formatNumberValue } from '~/services/formatLocale'

export interface AdminStackedBarDataset {
label: string
data: number[]
color: string
}

export function buildAdminStackedBarChartData(
labels: string[],
series: AdminStackedBarDataset[],
): ChartData<'bar'> {
return {
labels,
datasets: series.map(item => ({
label: item.label,
data: item.data,
backgroundColor: item.color,
borderColor: item.color,
borderWidth: 0,
borderRadius: 4,
borderSkipped: false,
})),
}
}

export function formatAdminStackedBarTooltip(label: string, value: number, total: number) {
const percentage = total > 0 ? (value / total) * 100 : 0
return `${label}: ${formatNumberValue(value)} (${formatNumberValue(percentage, { maximumFractionDigits: 1 })}%)`
}

export function buildAdminStackedBarChartOptions(isDark: boolean): ChartOptions<'bar'> {
const textColor = isDark ? '#d1d5db' : '#4b5563'
const mutedTextColor = isDark ? '#9ca3af' : '#6b7280'

return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
layout: {
padding: {
left: 0,
right: 0,
top: 10,
bottom: 10,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: textColor,
boxHeight: 10,
boxWidth: 10,
font: {
size: 12,
weight: 500,
},
padding: 18,
usePointStyle: true,
},
},
tooltip: {
backgroundColor: isDark ? 'rgba(31, 41, 55, 0.95)' : 'rgba(255, 255, 255, 0.95)',
titleColor: isDark ? '#f3f4f6' : '#1f2937',
bodyColor: textColor,
borderColor: isDark ? '#374151' : '#e5e7eb',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: (context) => {
const value = Number(context.parsed.y ?? 0)
const total = context.chart.data.datasets.reduce((sum, dataset) => {
return sum + Number(dataset.data[context.dataIndex] ?? 0)
}, 0)
return formatAdminStackedBarTooltip(context.dataset.label || '', value, total)
},
},
},
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
color: mutedTextColor,
maxRotation: 0,
minRotation: 0,
autoSkip: true,
maxTicksLimit: 10,
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: isDark ? 'rgba(75, 85, 99, 0.3)' : 'rgba(229, 231, 235, 0.8)',
},
ticks: {
color: mutedTextColor,
precision: 0,
callback: value => typeof value === 'number' ? formatNumberValue(value) : value,
},
},
},
}
}
55 changes: 55 additions & 0 deletions src/pages/admin/dashboard/users.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import AdminBarChart from '~/components/admin/AdminBarChart.vue'
import AdminFilterBar from '~/components/admin/AdminFilterBar.vue'
import AdminFunnelChart from '~/components/admin/AdminFunnelChart.vue'
import AdminMultiLineChart from '~/components/admin/AdminMultiLineChart.vue'
import AdminStackedBarChart from '~/components/admin/AdminStackedBarChart.vue'
import AdminStatsCard from '~/components/admin/AdminStatsCard.vue'
import ChartCard from '~/components/dashboard/ChartCard.vue'
import PageLoader from '~/components/PageLoader.vue'
Expand Down Expand Up @@ -68,6 +69,12 @@ interface OnboardingFunnelData {
org_joins_invite_register: number
org_joins_existing_account: number
}>
registration_source_trend: Array<{
date: string
normal_registrations: number
invite_registrations: number
without_profile: number
}>
}

interface EmailTypeBreakdown {
Expand Down Expand Up @@ -1104,6 +1111,39 @@ const inviteJoinTrendSeries = computed(() => {
]
})

const registrationSourceTrendSeries = computed(() => {
const trend = onboardingFunnelData.value?.registration_source_trend
if (!trend || trend.length === 0)
return []

return [
{
label: t('normal-registration'),
data: trend.map(item => ({
date: item.date,
value: Number(item.normal_registrations) || 0,
})),
color: '#3b82f6',
},
{
label: t('organization-invite'),
data: trend.map(item => ({
date: item.date,
value: Number(item.invite_registrations) || 0,
})),
color: '#f97316',
},
{
label: t('without-profile'),
data: trend.map(item => ({
date: item.date,
value: Number(item.without_profile) || 0,
})),
color: '#94a3b8',
},
]
})

watch(() => adminStore.activeDateRange, () => {
loadGlobalStatsTrend()
loadOnboardingFunnel()
Expand Down Expand Up @@ -1198,6 +1238,21 @@ displayStore.defaultBack = '/dashboard'
</div>
</div>

<!-- Registration Source Trend Chart -->
<ChartCard
:title="t('registrations-by-source')"
:is-loading="isLoadingOnboardingFunnel"
:has-data="registrationSourceTrendSeries.length > 0"
>
<p class="mb-3 text-sm text-slate-500 dark:text-slate-400">
{{ t('registrations-by-source-description') }}
</p>
<AdminStackedBarChart
:series="registrationSourceTrendSeries"
:is-loading="isLoadingOnboardingFunnel"
/>
</ChartCard>

<!-- Onboarding Trend Chart -->
<ChartCard
:title="t('onboarding-trend')"
Expand Down
Loading
Loading