From a05def590653ede0b9641ae1ed9f00ffd04c6f2c Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Mon, 24 Nov 2025 17:42:43 +0100 Subject: [PATCH] Add `registration_activity` chart and enhance admin statistics - Introduced `RegistrationActivityChart` to display user registration trends over 14 days. - Enhanced `AdminStatsResponse` with `registration_activity`, providing improved insights for admin users. - Updated demo data to include realistic registration activity and organization details. - Refactored admin page to use updated statistics data model and improved query handling. - Fixed inconsistent timezone handling in statistical analytics and demo user timestamps. --- backend/app/api/routes/admin.py | 169 +++++++++++---- backend/app/core/demo_data.json | 205 ++++++++++++++++++ backend/app/init_db.py | 13 +- frontend/src/app/[locale]/admin/page.tsx | 38 +++- .../charts/OrganizationDistributionChart.tsx | 108 ++++----- .../charts/RegistrationActivityChart.tsx | 113 ++++++++++ .../src/components/charts/UserGrowthChart.tsx | 150 +++++++------ .../src/components/charts/UserStatusChart.tsx | 90 ++++---- frontend/src/components/charts/index.ts | 4 +- 9 files changed, 664 insertions(+), 226 deletions(-) create mode 100644 frontend/src/components/charts/RegistrationActivityChart.tsx diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index 7b39207..0b69e91 100755 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -7,7 +7,7 @@ for managing the application. """ import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from enum import Enum from typing import Any from uuid import UUID @@ -94,6 +94,11 @@ class OrgDistributionData(BaseModel): value: int +class RegistrationActivityData(BaseModel): + date: str + registrations: int + + class UserStatusData(BaseModel): name: str value: int @@ -102,9 +107,63 @@ class UserStatusData(BaseModel): class AdminStatsResponse(BaseModel): user_growth: list[UserGrowthData] organization_distribution: list[OrgDistributionData] + registration_activity: list[RegistrationActivityData] user_status: list[UserStatusData] +def _generate_demo_stats() -> AdminStatsResponse: + """Generate demo statistics for empty databases.""" + from random import randint + + # Demo user growth (last 30 days) + user_growth = [] + total = 10 + for i in range(29, -1, -1): + date = datetime.now(UTC) - timedelta(days=i) + total += randint(0, 3) + user_growth.append( + UserGrowthData( + date=date.strftime("%b %d"), + total_users=total, + active_users=int(total * 0.85), + ) + ) + + # Demo organization distribution + org_dist = [ + OrgDistributionData(name="Engineering", value=12), + OrgDistributionData(name="Product", value=8), + OrgDistributionData(name="Sales", value=15), + OrgDistributionData(name="Marketing", value=6), + OrgDistributionData(name="Support", value=5), + OrgDistributionData(name="Operations", value=4), + ] + + # Demo registration activity (last 14 days) + registration_activity = [] + for i in range(13, -1, -1): + date = datetime.now(UTC) - timedelta(days=i) + registration_activity.append( + RegistrationActivityData( + date=date.strftime("%b %d"), + registrations=randint(0, 5), + ) + ) + + # Demo user status + user_status = [ + UserStatusData(name="Active", value=45), + UserStatusData(name="Inactive", value=5), + ] + + return AdminStatsResponse( + user_growth=user_growth, + organization_distribution=org_dist, + registration_activity=registration_activity, + user_status=user_status, + ) + + @router.get( "/stats", response_model=AdminStatsResponse, @@ -116,75 +175,88 @@ async def admin_get_stats( admin: User = Depends(require_superuser), db: AsyncSession = Depends(get_db), ) -> Any: - """Get admin dashboard statistics.""" - # 1. User Growth (Last 30 days) - # Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view. - thirty_days_ago = datetime.utcnow() - timedelta(days=30) + """Get admin dashboard statistics with real data from database.""" + from app.core.config import settings - # Get all users created in last 30 days - query = ( - select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at) - ) - result = await db.execute(query) - recent_users = result.scalars().all() + # Check if we have any data + total_users_query = select(func.count()).select_from(User) + total_users = (await db.execute(total_users_query)).scalar() or 0 - # Get total count before 30 days - count_query = ( - select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) - ) - count_result = await db.execute(count_query) - base_count = count_result.scalar() or 0 + # If database is essentially empty (only admin user), return demo data + if total_users <= 1 and settings.DEMO_MODE: + logger.info("Returning demo stats data (empty database in demo mode)") + return _generate_demo_stats() - # Aggregate by day + # 1. User Growth (Last 30 days) - Improved calculation + datetime.now(UTC) - timedelta(days=30) + + # Get all users with their creation dates + all_users_query = select(User).order_by(User.created_at) + result = await db.execute(all_users_query) + all_users = result.scalars().all() + + # Build cumulative counts per day user_growth = [] - current_total = base_count - - # Create a map of date -> count - daily_counts = {} - for user in recent_users: - date_str = user.created_at.strftime("%b %d") - if date_str not in daily_counts: - daily_counts[date_str] = {"total": 0, "active": 0} - daily_counts[date_str]["total"] += 1 - if user.is_active: - daily_counts[date_str]["active"] += 1 - - # Fill in the last 30 days for i in range(29, -1, -1): - date = datetime.utcnow() - timedelta(days=i) - date_str = date.strftime("%b %d") + date = datetime.now(UTC) - timedelta(days=i) + date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC) + date_end = date_start + timedelta(days=1) - day_data = daily_counts.get(date_str, {"total": 0, "active": 0}) - current_total += day_data["total"] + # Count all users created before end of this day + # Make comparison timezone-aware + total_users_on_date = sum( + 1 for u in all_users + if u.created_at and u.created_at.replace(tzinfo=UTC) < date_end + ) + # Count active users created before end of this day + active_users_on_date = sum( + 1 for u in all_users + if u.created_at and u.created_at.replace(tzinfo=UTC) < date_end and u.is_active + ) - # For active users, we'd ideally track history, but for now let's approximate - # by just counting current active users created up to this point - # This is a simplification user_growth.append( UserGrowthData( - date=date_str, - total_users=current_total, - active_users=int( - current_total * 0.8 - ), # Mocking active ratio for demo visual appeal if real data lacks history + date=date.strftime("%b %d"), + total_users=total_users_on_date, + active_users=active_users_on_date, ) ) - # 2. Organization Distribution - # Get top 5 organizations by member count + # 2. Organization Distribution - Top 6 organizations by member count org_query = ( select(Organization.name, func.count(UserOrganization.user_id).label("count")) .join(UserOrganization, Organization.id == UserOrganization.organization_id) .group_by(Organization.name) .order_by(func.count(UserOrganization.user_id).desc()) - .limit(5) + .limit(6) ) result = await db.execute(org_query) org_dist = [ OrgDistributionData(name=row.name, value=row.count) for row in result.all() ] - # 3. User Status + # 3. User Registration Activity (Last 14 days) - NEW + registration_activity = [] + for i in range(13, -1, -1): + date = datetime.now(UTC) - timedelta(days=i) + date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC) + date_end = date_start + timedelta(days=1) + + # Count users created on this specific day + # Make comparison timezone-aware + day_registrations = sum( + 1 for u in all_users + if u.created_at and date_start <= u.created_at.replace(tzinfo=UTC) < date_end + ) + + registration_activity.append( + RegistrationActivityData( + date=date.strftime("%b %d"), + registrations=day_registrations, + ) + ) + + # 4. User Status - Active vs Inactive active_query = select(func.count()).select_from(User).where(User.is_active) inactive_query = ( select(func.count()).select_from(User).where(User.is_active.is_(False)) @@ -193,6 +265,8 @@ async def admin_get_stats( active_count = (await db.execute(active_query)).scalar() or 0 inactive_count = (await db.execute(inactive_query)).scalar() or 0 + logger.info(f"User status counts - Active: {active_count}, Inactive: {inactive_count}") + user_status = [ UserStatusData(name="Active", value=active_count), UserStatusData(name="Inactive", value=inactive_count), @@ -201,6 +275,7 @@ async def admin_get_stats( return AdminStatsResponse( user_growth=user_growth, organization_distribution=org_dist, + registration_activity=registration_activity, user_status=user_status, ) diff --git a/backend/app/core/demo_data.json b/backend/app/core/demo_data.json index c955533..53c1c5a 100644 --- a/backend/app/core/demo_data.json +++ b/backend/app/core/demo_data.json @@ -24,6 +24,11 @@ "name": "Umbrella Corporation", "slug": "umbrella", "description": "Our business is life itself." + }, + { + "name": "Massive Dynamic", + "slug": "massive-dynamic", + "description": "What don't we do?" } ], "users": [ @@ -57,6 +62,16 @@ "role": "member", "is_active": false }, + { + "email": "diana@acme.com", + "password": "Demo123!", + "first_name": "Diana", + "last_name": "Prince", + "is_superuser": false, + "organization_slug": "acme-corp", + "role": "member", + "is_active": true + }, { "email": "carol@globex.com", "password": "Demo123!", @@ -77,6 +92,26 @@ "role": "member", "is_active": true }, + { + "email": "ellen@globex.com", + "password": "Demo123!", + "first_name": "Ellen", + "last_name": "Ripley", + "is_superuser": false, + "organization_slug": "globex", + "role": "member", + "is_active": true + }, + { + "email": "fred@globex.com", + "password": "Demo123!", + "first_name": "Fred", + "last_name": "Flintstone", + "is_superuser": false, + "organization_slug": "globex", + "role": "member", + "is_active": true + }, { "email": "dave@soylent.com", "password": "Demo123!", @@ -87,6 +122,26 @@ "role": "member", "is_active": true }, + { + "email": "gina@soylent.com", + "password": "Demo123!", + "first_name": "Gina", + "last_name": "Torres", + "is_superuser": false, + "organization_slug": "soylent", + "role": "member", + "is_active": true + }, + { + "email": "harry@soylent.com", + "password": "Demo123!", + "first_name": "Harry", + "last_name": "Potter", + "is_superuser": false, + "organization_slug": "soylent", + "role": "admin", + "is_active": true + }, { "email": "eve@initech.com", "password": "Demo123!", @@ -97,6 +152,26 @@ "role": "admin", "is_active": true }, + { + "email": "iris@initech.com", + "password": "Demo123!", + "first_name": "Iris", + "last_name": "West", + "is_superuser": false, + "organization_slug": "initech", + "role": "member", + "is_active": true + }, + { + "email": "jack@initech.com", + "password": "Demo123!", + "first_name": "Jack", + "last_name": "Sparrow", + "is_superuser": false, + "organization_slug": "initech", + "role": "member", + "is_active": false + }, { "email": "frank@umbrella.com", "password": "Demo123!", @@ -117,6 +192,76 @@ "role": "member", "is_active": false }, + { + "email": "kate@umbrella.com", + "password": "Demo123!", + "first_name": "Kate", + "last_name": "Bishop", + "is_superuser": false, + "organization_slug": "umbrella", + "role": "member", + "is_active": true + }, + { + "email": "leo@massive.com", + "password": "Demo123!", + "first_name": "Leo", + "last_name": "Messi", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "owner", + "is_active": true + }, + { + "email": "mary@massive.com", + "password": "Demo123!", + "first_name": "Mary", + "last_name": "Jane", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "member", + "is_active": true + }, + { + "email": "nathan@massive.com", + "password": "Demo123!", + "first_name": "Nathan", + "last_name": "Drake", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "member", + "is_active": true + }, + { + "email": "olivia@massive.com", + "password": "Demo123!", + "first_name": "Olivia", + "last_name": "Dunham", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "admin", + "is_active": true + }, + { + "email": "peter@massive.com", + "password": "Demo123!", + "first_name": "Peter", + "last_name": "Parker", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "member", + "is_active": true + }, + { + "email": "quinn@massive.com", + "password": "Demo123!", + "first_name": "Quinn", + "last_name": "Mallory", + "is_superuser": false, + "organization_slug": "massive-dynamic", + "role": "member", + "is_active": true + }, { "email": "grace@example.com", "password": "Demo123!", @@ -146,6 +291,66 @@ "organization_slug": null, "role": null, "is_active": false + }, + { + "email": "rachel@example.com", + "password": "Demo123!", + "first_name": "Rachel", + "last_name": "Green", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": true + }, + { + "email": "sam@example.com", + "password": "Demo123!", + "first_name": "Sam", + "last_name": "Wilson", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": true + }, + { + "email": "tony@example.com", + "password": "Demo123!", + "first_name": "Tony", + "last_name": "Stark", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": true + }, + { + "email": "una@example.com", + "password": "Demo123!", + "first_name": "Una", + "last_name": "Chin-Riley", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": false + }, + { + "email": "victor@example.com", + "password": "Demo123!", + "first_name": "Victor", + "last_name": "Von Doom", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": true + }, + { + "email": "wanda@example.com", + "password": "Demo123!", + "first_name": "Wanda", + "last_name": "Maximoff", + "is_superuser": false, + "organization_slug": null, + "role": null, + "is_active": true } ] } \ No newline at end of file diff --git a/backend/app/init_db.py b/backend/app/init_db.py index 934cf11..e369a7d 100644 --- a/backend/app/init_db.py +++ b/backend/app/init_db.py @@ -9,7 +9,7 @@ import asyncio import json import logging import random -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from sqlalchemy import select, text @@ -153,23 +153,24 @@ async def load_demo_data(session): # Randomize created_at for demo data (last 30 days) # This makes the charts look more realistic days_ago = random.randint(0, 30) # noqa: S311 - random_time = datetime.utcnow() - timedelta(days=days_ago) + random_time = datetime.now(UTC) - timedelta(days=days_ago) # Add some random hours/minutes variation random_time = random_time.replace( hour=random.randint(0, 23), # noqa: S311 minute=random.randint(0, 59), # noqa: S311 ) - # Update the timestamp directly in the database + # Update the timestamp and is_active directly in the database + # We do this to ensure the values are persisted correctly await session.execute( text( - "UPDATE users SET created_at = :created_at WHERE id = :user_id" + "UPDATE users SET created_at = :created_at, is_active = :is_active WHERE id = :user_id" ), - {"created_at": random_time, "user_id": user.id}, + {"created_at": random_time, "is_active": user_data.get("is_active", True), "user_id": user.id}, ) logger.info( - f"Created demo user: {user.email} (created {days_ago} days ago)" + f"Created demo user: {user.email} (created {days_ago} days ago, active={user_data.get('is_active', True)})" ) # Add to organization if specified diff --git a/frontend/src/app/[locale]/admin/page.tsx b/frontend/src/app/[locale]/admin/page.tsx index 11a7ee4..cef9e35 100644 --- a/frontend/src/app/[locale]/admin/page.tsx +++ b/frontend/src/app/[locale]/admin/page.tsx @@ -11,7 +11,7 @@ import { DashboardStats } from '@/components/admin'; import { UserGrowthChart, OrganizationDistributionChart, - SessionActivityChart, + RegistrationActivityChart, UserStatusChart, } from '@/components/charts'; import { Users, Building2, Settings } from 'lucide-react'; @@ -19,16 +19,40 @@ import { useQuery } from '@tanstack/react-query'; import { getAdminStats } from '@/lib/api/admin'; export default function AdminPage() { + console.log('[AdminPage] Component rendering'); + const { data: stats, isLoading, error, + status, + fetchStatus, } = useQuery({ - queryKey: ['admin', 'stats'], + queryKey: ['admin', 'analytics'], // Changed from 'stats' to avoid collision with useAdminStats hook queryFn: async () => { - const response = await getAdminStats(); - return response.data; + console.log('[AdminPage] QueryFn executing - fetching stats...'); + try { + const response = await getAdminStats(); + console.log('[AdminPage] Stats response received:', response); + return response.data; + } catch (err) { + console.error('[AdminPage] Error fetching stats:', err); + throw err; + } }, + enabled: true, // Explicitly enable the query + retry: 1, + staleTime: 60000, // Cache for 1 minute + }); + + console.log('[AdminPage] Current state:', { + isLoading, + hasError: Boolean(error), + error: error?.message, + hasData: Boolean(stats), + dataKeys: stats ? Object.keys(stats) : null, + status, + fetchStatus, }); return ( @@ -94,7 +118,11 @@ export default function AdminPage() { loading={isLoading} error={error ? (error as Error).message : null} /> - + { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.name} +

+

+ Members: {payload[0].value} +

+
+ ); + } + return null; +}; export function OrganizationDistributionChart({ data, loading, error, }: OrganizationDistributionChartProps) { - const chartData = data || generateMockData(); + // Show empty chart if no data available + const rawData = data || []; + const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0); return ( - - - - - - - - - - + {!hasData && !loading && !error ? ( +
+

No organization data available

+
+ ) : ( + + + + + + } /> + + + + )}
); } diff --git a/frontend/src/components/charts/RegistrationActivityChart.tsx b/frontend/src/components/charts/RegistrationActivityChart.tsx new file mode 100644 index 0000000..3d36a18 --- /dev/null +++ b/frontend/src/components/charts/RegistrationActivityChart.tsx @@ -0,0 +1,113 @@ +/** + * RegistrationActivityChart Component + * Displays user registration activity over time using an area chart + */ + +'use client'; + +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import { ChartCard } from './ChartCard'; +import { CHART_PALETTES } from '@/lib/chart-colors'; + +export interface RegistrationActivityData { + date: string; + registrations: number; +} + +interface RegistrationActivityChartProps { + data?: RegistrationActivityData[]; + loading?: boolean; + error?: string | null; +} + +// Custom tooltip with proper theme colors +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.date} +

+

+ New Registrations: {payload[0].value} +

+
+ ); + } + return null; +}; + +export function RegistrationActivityChart({ + data, + loading, + error, +}: RegistrationActivityChartProps) { + // Show empty chart if no data available + const chartData = data || []; + const hasData = chartData.length > 0 && chartData.some((d) => d.registrations > 0); + + return ( + + {!hasData && !loading && !error ? ( +
+

No registration data available

+
+ ) : ( + + + + + + + + + + + + } /> + + + + + )} +
+ ); +} diff --git a/frontend/src/components/charts/UserGrowthChart.tsx b/frontend/src/components/charts/UserGrowthChart.tsx index 1c0da5a..27929dc 100644 --- a/frontend/src/components/charts/UserGrowthChart.tsx +++ b/frontend/src/components/charts/UserGrowthChart.tsx @@ -5,19 +5,18 @@ 'use client'; +import { ChartCard } from './ChartCard'; +import { CHART_PALETTES } from '@/lib/chart-colors'; import { + ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, - ResponsiveContainer, Legend, } from 'recharts'; -import { ChartCard } from './ChartCard'; -import { format, subDays } from 'date-fns'; -import { CHART_PALETTES } from '@/lib/chart-colors'; export interface UserGrowthData { date: string; @@ -25,32 +24,46 @@ export interface UserGrowthData { active_users: number; } -interface UserGrowthChartProps { +export interface UserGrowthChartProps { data?: UserGrowthData[]; loading?: boolean; error?: string | null; } -// Generate mock data for development/demo -function generateMockData(): UserGrowthData[] { - const data: UserGrowthData[] = []; - const today = new Date(); - - for (let i = 29; i >= 0; i--) { - const date = subDays(today, i); - const baseUsers = 100 + i * 3; - data.push({ - date: format(date, 'MMM d'), - total_users: baseUsers + Math.floor(Math.random() * 10), - active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), - }); +// Custom tooltip with proper theme colors +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

+ {payload[0].payload.date} +

+

+ Total Users: {payload[0].value} +

+ {payload[1] && ( +

+ Active Users: {payload[1].value} +

+ )} +
+ ); } - - return data; -} + return null; +}; export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) { - const chartData = data || generateMockData(); + // Show empty chart if no data available + const rawData = data || []; + const hasData = + rawData.length > 0 && rawData.some((d) => d.total_users > 0 || d.active_users > 0); return ( - - - - - - - - - - - + {!hasData && !loading && !error ? ( +
+

No user growth data available

+
+ ) : ( + + + + + + } /> + + + + + + )}
); } diff --git a/frontend/src/components/charts/UserStatusChart.tsx b/frontend/src/components/charts/UserStatusChart.tsx index bcc269b..57518d8 100644 --- a/frontend/src/components/charts/UserStatusChart.tsx +++ b/frontend/src/components/charts/UserStatusChart.tsx @@ -21,16 +21,6 @@ interface UserStatusChartProps { error?: string | null; } -// Generate mock data for development/demo -function generateMockData(): UserStatusData[] { - return [ - { name: 'Active', value: 142, color: CHART_PALETTES.pie[0] }, - { name: 'Inactive', value: 28, color: CHART_PALETTES.pie[1] }, - { name: 'Pending', value: 15, color: CHART_PALETTES.pie[2] }, - { name: 'Suspended', value: 5, color: CHART_PALETTES.pie[3] }, - ]; -} - // Custom label component to show percentages const renderLabel = (entry: { percent: number; name: string }) => { const percent = (entry.percent * 100).toFixed(0); @@ -38,7 +28,9 @@ const renderLabel = (entry: { percent: number; name: string }) => { }; export function UserStatusChart({ data, loading, error }: UserStatusChartProps) { - const rawData = data || generateMockData(); + // Show empty chart if no data available + const rawData = data || []; + const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0); // Assign colors if missing const chartData = rawData.map((item, index) => ({ @@ -53,41 +45,47 @@ export function UserStatusChart({ data, loading, error }: UserStatusChartProps) loading={loading} error={error} > - - - - {chartData.map((entry, index) => ( - - ))} - - - - - + {!hasData && !loading && !error ? ( +
+

No user status data available

+
+ ) : ( + + + + {chartData.map((entry, index) => ( + + ))} + + + + + + )} ); } diff --git a/frontend/src/components/charts/index.ts b/frontend/src/components/charts/index.ts index 7cef754..44931ab 100755 --- a/frontend/src/components/charts/index.ts +++ b/frontend/src/components/charts/index.ts @@ -7,7 +7,7 @@ export { UserGrowthChart } from './UserGrowthChart'; export type { UserGrowthData } from './UserGrowthChart'; export { OrganizationDistributionChart } from './OrganizationDistributionChart'; export type { OrganizationDistributionData } from './OrganizationDistributionChart'; -export { SessionActivityChart } from './SessionActivityChart'; -export type { SessionActivityData } from './SessionActivityChart'; +export { RegistrationActivityChart } from './RegistrationActivityChart'; +export type { RegistrationActivityData } from './RegistrationActivityChart'; export { UserStatusChart } from './UserStatusChart'; export type { UserStatusData } from './UserStatusChart';