Repository navigation
feat(admin): chart registration sources #2963
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 6 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
636e6c3
docs: design registration source trend chart
WcaleNieWolny 2d6519f
docs: plan registration source trend chart
WcaleNieWolny 0705b61
feat(admin): report registration profile sources
WcaleNieWolny c8e5892
feat(admin): add stacked bar chart component
WcaleNieWolny 0722917
feat(admin): chart registration sources
WcaleNieWolny 18ec354
fix(admin): address registration trend review
WcaleNieWolny e38a51f
fix(admin): address chart review feedback
WcaleNieWolny 28d7a96
docs: reset database before admin stats tests
WcaleNieWolny 96449f9
refactor(admin): name registration trend points
WcaleNieWolny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
480 changes: 480 additions & 0 deletions
480
docs/superpowers/plans/2026-08-09-admin-registration-source-trend.md
Large diffs are not rendered by default.
Oops, something went wrong.
105 changes: 105 additions & 0 deletions
105
docs/superpowers/specs/2026-08-09-admin-registration-source-trend-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" /> | ||
| </div> | ||
| <div v-else class="w-full h-full"> | ||
| <Bar class="h-full w-full" :data="chartData" :options="chartOptions" /> | ||
| </div> | ||
| </div> | ||
| </template> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.