AI-Powered SaaS Analytics Dashboard
Build a full-stack AI analytics dashboard with React, Django, and an LLM.
Introduction
30 Second Summary
Every SaaS product tracks metrics like monthly revenue, user activity, and churn rates. The real challenge is not collecting data, but spotting the hidden patterns and explaining what they mean in plain English.
In this project, you will build an AI-powered analytics dashboard that detects anomalies in revenue data, predicts which accounts are at risk of churning, and uses a large language model to generate natural language insight cards explaining what is happening and what to do about it.
What You'll Build
A full-stack SaaS analytics dashboard with a Django REST Framework backend, a React frontend with interactive charts, and AI-generated insight cards powered by the Groq LLM API.
By the end of this project, you'll have:
- A Django backend with analytics endpoints that compute anomaly detection, churn risk scoring, and revenue forecasting from mock SaaS data.
- An AI insights engine that sends computed analytics to the Groq LLM API and returns human-readable insight cards with severity levels and recommended actions.
- A React dashboard with Recharts visualizations and KPI summary cards fetching live data from your API.
- A fully containerized application running locally via Docker Compose with Django, PostgreSQL, and a React dev server.
- Secret Mission: Add a trend comparison feature that lets users select two time periods and generates an AI-powered comparative analysis.
Are there any prerequisites?
This project assumes you have experience with Django REST Framework and basic React knowledge. You should also have Python and Node.js installed on your machine.
You will install Docker Desktop and create a free Groq API key (no credit card required) during the first step.
Scaffold the Project and Docker Environment
Your tools are ready. Now you need to build the actual project structure that connects a React frontend, a Django backend, and a PostgreSQL database into a single containerized stack.
In this step, you will scaffold both applications, define how Docker containers talk to each other, and populate the database with realistic mock data that the AI engine will analyze later.
In this step, get ready to:
- Scaffold the Django backend and React frontend project directories.
- Create Dockerfiles and a Docker Compose configuration to run the full stack.
- Configure Django with DRF, create database models, run migrations, and seed data.
Create the project structure and scaffold the apps
You need a top-level project folder with two subdirectories: backend/ for Django and frontend/ for React.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir saas-analytics-dashboard
cd saas-analytics-dashboard
Django needs to be installed locally to run the scaffolding command. You will create a temporary virtual environment for this.
- Create and activate a virtual environment by running:
python3 -m venv .venv
source .venv/bin/activate
On Windows?
Replace the second line with .venv\Scripts\activate instead.
- Install Django in your virtual environment by running:
pip install django==5.2.3
- Scaffold the Django project with the config package inside a backend/ directory by running:
django-admin startproject config backend
What does this command do?
django-admin startproject config backend tells Django to create a project named config inside a directory called backend/. This gives you backend/manage.py and a backend/config/ package containing settings.py and urls.py.
- Create the Django analytics app by running:
cd backend
python manage.py startapp analytics
cd ..
- Scaffold the React frontend using Vite by running:
npm create vite@latest frontend -- --template react
- Move into the frontend/ directory and install its dependencies by running:
cd frontend
npm install
cd ..
- Deactivate your virtual environment since everything else runs inside Docker:
deactivate
- Confirm your project structure by running:
ls
You should see backend and frontend directories listed in the output.
Not seeing both directories?
Make sure you ran django-admin startproject config backend and npm create vite@latest frontend -- --template react from the saas-analytics-dashboard folder. If either command failed, check that Django installed correctly in your virtual environment and that Node.js is available.
help me troubleshoot my project scaffolding
- Open the saas-analytics-dashboard folder in VS Code by running:
code .
- In VS Code, create a new file called .env in the project root (the saas-analytics-dashboard folder).
- Add the following line, replacing the placeholder with your actual Groq API key from the previous step:
GROQ_API_KEY=your-groq-api-key-here
- Save .env.
Why a .env file?
Docker Compose automatically reads variables from a .env file in the same directory as your docker-compose.yml. The ${GROQ_API_KEY} reference in the Compose file pulls the value from this file and injects it into the backend container.
Create the Docker environment
Each part of the stack needs its own container image. You will create Dockerfiles for the backend and frontend, then wire everything together with Docker Compose.
- Create a new file backend/requirements.txt and paste the following dependencies:
django==5.2.3
djangorestframework==3.16.0
django-cors-headers==4.7.0
psycopg2-binary==2.9.10
groq==0.25.0
numpy==2.2.6
python-dotenv==1.1.0
What are these packages?
djangorestframework provides REST API views. django-cors-headers lets the React frontend call the Django API from a different port. psycopg2-binary connects Django to PostgreSQL.
groq is the official Python SDK for the Groq LLM API. numpy handles statistical computations for anomaly detection. python-dotenv loads environment variables from .env files.
- Create the backend Dockerfile at backend/Dockerfile with this content:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
What does this Dockerfile do?
It starts from a slim Python 3.12 image, copies requirements.txt first (for Docker layer caching), installs all dependencies, then copies the rest of the backend code.
- Create the frontend Dockerfile at frontend/Dockerfile with this content:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
Why --host in the CMD?
By default, Vite only listens on localhost inside the container. The --host flag tells Vite to bind to 0.0.0.0 so that Docker can forward traffic from your machine into the container.
- Create docker-compose.yml in the project root with the following content:
services:
db:
image: postgres:16
environment:
POSTGRES_DB: saas_analytics
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
backend:
build: ./backend
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/saas_analytics
- GROQ_API_KEY=${GROQ_API_KEY}
depends_on:
- db
frontend:
build: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
depends_on:
- backend
volumes:
pgdata:
What does each service do?
db runs PostgreSQL 16 and creates a database called saas_analytics automatically on first start.
backend builds from the backend/Dockerfile and runs Django's development server. The volumes mount syncs your local code changes into the container without rebuilding.
frontend builds from the frontend/Dockerfile and runs Vite's dev server. The anonymous /app/node_modules volume prevents your host node_modules from overwriting the container's installed packages.
- Save all files.
Unsure about file locations?
Your project tree should look like this: docker-compose.yml and .env are in the root saas-analytics-dashboard/ folder. Dockerfile and requirements.txt are inside backend/. The other Dockerfile is inside frontend/.
help me check my project file structure
Configure Django, create models, and seed the database
Now you need to configure Django to use PostgreSQL, register the DRF and CORS packages, and define the data models that store SaaS metrics.
- Open backend/config/settings.py and replace its entire contents with the following:
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-dev-key-change-in-production"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"analytics",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
CORS_ALLOW_ALL_ORIGINS = True
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "saas_analytics",
"USER": "postgres",
"PASSWORD": "postgres",
"HOST": "db",
"PORT": "5432",
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
What is important in this settings file?
DATABASES["default"]["HOST"] is set to "db". Inside Docker Compose, services can reach each other by their service name. The db hostname resolves to the PostgreSQL container.
CORS_ALLOW_ALL_ORIGINS = True lets the React frontend (port 5173) call the Django API (port 8000) without cross-origin errors. corsheaders.middleware.CorsMiddleware must be first in the middleware list to intercept preflight requests.
- Open backend/config/urls.py and replace its entire contents with:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("analytics.urls")),
]
- Create a placeholder URL file so Django can start without errors. Create backend/analytics/urls.py with:
from django.urls import path
urlpatterns = []
- Open backend/analytics/models.py and replace its contents with:
from django.db import models
class Account(models.Model):
company_name = models.CharField(max_length=200)
plan = models.CharField(max_length=50)
mrr = models.DecimalField(max_digits=10, decimal_places=2)
signup_date = models.DateField()
last_active = models.DateField()
team_size = models.IntegerField(default=1)
def __str__(self):
return self.company_name
class ActivityLog(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="activities")
date = models.DateField()
logins = models.IntegerField(default=0)
api_calls = models.IntegerField(default=0)
features_used = models.IntegerField(default=0)
class Meta:
unique_together = ["account", "date"]
class SubscriptionEvent(models.Model):
EVENT_TYPES = [
("upgrade", "Upgrade"),
("downgrade", "Downgrade"),
("cancellation", "Cancellation"),
("renewal", "Renewal"),
]
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
date = models.DateField()
old_plan = models.CharField(max_length=50, blank=True)
new_plan = models.CharField(max_length=50, blank=True)
def __str__(self):
return f"{self.account.company_name} - {self.event_type}"
class RevenueDaily(models.Model):
date = models.DateField(unique=True)
mrr_total = models.DecimalField(max_digits=12, decimal_places=2)
new_revenue = models.DecimalField(max_digits=10, decimal_places=2)
churned_revenue = models.DecimalField(max_digits=10, decimal_places=2)
active_accounts = models.IntegerField()
class Meta:
ordering = ["date"]
What do these models represent?
Account stores each customer company with their current plan and MRR. ActivityLog tracks daily engagement metrics per account.
SubscriptionEvent records plan changes like upgrades and downgrades. RevenueDaily stores aggregate daily revenue figures. These are the tables the analytics engine will query for anomaly detection and churn prediction.
- Create the seed data management command directory structure by running from the project root terminal:
mkdir -p backend/analytics/management/commands
touch backend/analytics/management/__init__.py
touch backend/analytics/management/commands/__init__.py
- Create backend/analytics/management/commands/seed_data.py with the following content:
import random
from datetime import date, timedelta
from decimal import Decimal
from django.core.management.base import BaseCommand
from analytics.models import Account, ActivityLog, SubscriptionEvent, RevenueDaily
class Command(BaseCommand):
help = "Seed the database with 6 months of mock SaaS metrics"
def handle(self, *args, **options):
# Clear existing data
Account.objects.all().delete()
ActivityLog.objects.all().delete()
SubscriptionEvent.objects.all().delete()
RevenueDaily.objects.all().delete()
# Create accounts with different plans
plans = [
("starter", 49),
("growth", 149),
("enterprise", 499),
]
companies = [
"Acme Corp", "TechFlow", "DataPipe", "CloudSync", "DevHub",
"MetricBase", "ScaleUp", "PipelineIO", "FlowState", "CodeNest",
"InfraStack", "AutoDeploy", "ByteForge", "NodeCraft", "StreamOps",
"VaultGrid", "SparkLayer", "QuantumLeap", "NexGen", "CoreLogic",
]
today = date.today()
start_date = today - timedelta(days=180)
accounts = []
for i, name in enumerate(companies):
plan_name, base_mrr = plans[i % 3]
mrr_variation = random.uniform(0.8, 1.2)
account = Account.objects.create(
company_name=name,
plan=plan_name,
mrr=Decimal(str(round(base_mrr * mrr_variation, 2))),
signup_date=start_date - timedelta(days=random.randint(30, 365)),
last_active=today - timedelta(days=random.randint(0, 30)),
team_size=random.randint(2, 50),
)
accounts.append(account)
# Generate daily activity logs
for account in accounts:
current = start_date
while current <= today:
if random.random() > 0.1: # 90% chance of activity
ActivityLog.objects.create(
account=account,
date=current,
logins=random.randint(0, 15),
api_calls=random.randint(10, 500),
features_used=random.randint(1, 8),
)
current += timedelta(days=1)
# Generate subscription events
for account in accounts:
if random.random() < 0.3: # 30% chance of an event
event_date = start_date + timedelta(days=random.randint(60, 120))
SubscriptionEvent.objects.create(
account=account,
event_type=random.choice(["upgrade", "downgrade", "renewal"]),
date=event_date,
old_plan=account.plan,
new_plan=random.choice(["starter", "growth", "enterprise"]),
)
# Generate daily revenue with baked-in anomalies
base_mrr = 4500.00
current = start_date
while current <= today:
day_offset = (current - start_date).days
# Steady growth
growth = day_offset * 15
# Anomaly: revenue dip around day 90
anomaly = 0
if 88 <= day_offset <= 92:
anomaly = -1200
# Anomaly: churn spike around day 120
if 118 <= day_offset <= 122:
anomaly = -800
daily_mrr = base_mrr + growth + anomaly + random.uniform(-200, 200)
new_rev = random.uniform(50, 300)
churned = random.uniform(20, 150)
RevenueDaily.objects.create(
date=current,
mrr_total=Decimal(str(round(daily_mrr, 2))),
new_revenue=Decimal(str(round(new_rev, 2))),
churned_revenue=Decimal(str(round(churned, 2))),
active_accounts=random.randint(15, 20),
)
current += timedelta(days=1)
self.stdout.write(self.style.SUCCESS(
f"Seeded {len(accounts)} accounts with 6 months of data"
))
What does the seed command generate?
The command creates 20 mock companies, 6 months of daily activity logs, subscription events, and daily revenue data. It bakes in two anomalies: a revenue dip around day 90 and a churn spike around day 120. These patterns give the AI engine something meaningful to detect and explain.
- Save all files.
- Build and start the Docker Compose stack in detached mode by running:
docker compose up -d --build
Docker will pull the PostgreSQL image, build both the backend and frontend images, and start all three containers. This takes 1-2 minutes on the first run.
Build failed?
Make sure Docker Desktop is running. Check that backend/requirements.txt has no typos in package names. Verify that docker-compose.yml is in the project root alongside backend/ and frontend/.
help me fix my Docker Compose build errors
- Wait for the containers to start, then run migrations against the Dockerized PostgreSQL by running:
docker compose exec backend python manage.py makemigrations analytics
docker compose exec backend python manage.py migrate
What do these commands do?
makemigrations analytics generates a migration file from your models. migrate applies that migration (and Django's built-in ones) to the PostgreSQL database running inside the db container.
- Seed the database with 6 months of mock SaaS metrics by running:
docker compose exec backend python manage.py seed_data
You should see a success message: Seeded 20 accounts with 6 months of data.
- Verify all three containers are running by running:
docker compose ps
You should see three services listed (db, backend, frontend) all with status Up.
Migration or seed command failed?
If you see a connection refused error, the PostgreSQL container might still be starting. Wait 10 seconds and try the migration command again.
If you see a message about no installed app with label analytics, check that "analytics" is listed in INSTALLED_APPS in settings.py and that the backend/analytics/ folder contains an apps.py file.
help me debug my Django migration inside Docker
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and that docker compose ps shows all three services running.
ⓧ I'd like to double check the full code
Here are the key files you should have at this point.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: saas_analytics
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
backend:
build: ./backend
command: python manage.py runserver 0.0.0.0:8000
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/saas_analytics
- GROQ_API_KEY=${GROQ_API_KEY}
depends_on:
- db
frontend:
build: ./frontend
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
depends_on:
- backend
volumes:
pgdata:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
django==5.2.3
djangorestframework==3.16.0
django-cors-headers==4.7.0
psycopg2-binary==2.9.10
groq==0.25.0
numpy==2.2.6
python-dotenv==1.1.0
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-dev-key-change-in-production"
DEBUG = True
ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"analytics",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
CORS_ALLOW_ALL_ORIGINS = True
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "saas_analytics",
"USER": "postgres",
"PASSWORD": "postgres",
"HOST": "db",
"PORT": "5432",
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("analytics.urls")),
]
from django.db import models
class Account(models.Model):
company_name = models.CharField(max_length=200)
plan = models.CharField(max_length=50)
mrr = models.DecimalField(max_digits=10, decimal_places=2)
signup_date = models.DateField()
last_active = models.DateField()
team_size = models.IntegerField(default=1)
def __str__(self):
return self.company_name
class ActivityLog(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="activities")
date = models.DateField()
logins = models.IntegerField(default=0)
api_calls = models.IntegerField(default=0)
features_used = models.IntegerField(default=0)
class Meta:
unique_together = ["account", "date"]
class SubscriptionEvent(models.Model):
EVENT_TYPES = [
("upgrade", "Upgrade"),
("downgrade", "Downgrade"),
("cancellation", "Cancellation"),
("renewal", "Renewal"),
]
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
date = models.DateField()
old_plan = models.CharField(max_length=50, blank=True)
new_plan = models.CharField(max_length=50, blank=True)
def __str__(self):
return f"{self.account.company_name} - {self.event_type}"
class RevenueDaily(models.Model):
date = models.DateField(unique=True)
mrr_total = models.DecimalField(max_digits=12, decimal_places=2)
new_revenue = models.DecimalField(max_digits=10, decimal_places=2)
churned_revenue = models.DecimalField(max_digits=10, decimal_places=2)
active_accounts = models.IntegerField()
class Meta:
ordering = ["date"]
Your full-stack Docker environment is running with 6 months of seeded data. Next up, you will build the analytics engine that detects anomalies, predicts churn, and forecasts revenue from this mock data.
Build the Analytics Engine
Your Django app has models and seed data. Now you need the brains behind the dashboard.
In this step, you'll build three analytics functions that crunch the raw SaaS metrics into actionable signals: anomalies in revenue, accounts at risk of churning, and a revenue forecast. These computed results will later feed into the LLM so it can explain them in plain English.
In this step, get ready to:
- Create an anomaly detection function using z-score analysis on daily revenue.
- Build a churn prediction scoring system based on inactivity, declining usage, and plan downgrades.
- Implement revenue forecasting with a weighted moving average and confidence intervals.
Create the anomaly detection function
A revenue anomaly is any day where MRR jumps or drops far outside the recent norm. The standard way to quantify "far outside" is a z-score. A z-score measures how many standard deviations a data point sits from the mean.
- In VS Code, create a new file called services.py inside backend/analytics/.
- Add the imports and the detect_anomalies function by pasting this code:
import numpy as np
from datetime import date, timedelta
from django.db.models import Avg
from .models import Account, ActivityLog, RevenueDaily, SubscriptionEvent
def detect_anomalies(days=30):
# Pull revenue data for the analysis window plus 30 days of lookback
end_date = date.today()
start_date = end_date - timedelta(days=days + 30)
revenue_data = list(
RevenueDaily.objects.filter(date__gte=start_date, date__lte=end_date)
.values_list("date", "mrr_total")
.order_by("date")
)
# Need at least 30 data points to calculate a meaningful rolling mean
if len(revenue_data) < 30:
return []
values = [float(r[1]) for r in revenue_data]
dates = [r[0] for r in revenue_data]
# Slide a 30-day window and flag points with z-score > 2
anomalies = []
for i in range(30, len(values)):
window = values[i - 30 : i]
mean = np.mean(window)
std = np.std(window)
if std == 0:
continue
z_score = (values[i] - mean) / std
if abs(z_score) > 2:
anomalies.append(
{
"date": dates[i].isoformat(),
"value": values[i],
"expected": round(mean, 2),
"z_score": round(z_score, 2),
"direction": "spike" if z_score > 0 else "drop",
}
)
return anomalies
What does this code do?
The function queries the last 60 days of RevenueDaily records (30 days of lookback plus 30 days to analyse). For each day in the analysis window, it computes the mean and standard deviation of the preceding 30 days.
If a day's MRR is more than 2 standard deviations from that rolling mean, it is flagged as an anomaly. The result includes whether the anomaly was a spike (unusually high) or a drop (unusually low). This technique is called z-score analysis.
- Save services.py.
- Test the function by opening a Django shell inside the running container. Run this command:
docker compose exec backend python manage.py shell
- Inside the shell, run this to call the function:
from analytics.services import detect_anomalies
results = detect_anomalies()
print(f"Found {len(results)} anomalies")
if results:
print(results[0])
- You should see output showing one or more anomalies detected from the seed data. Each result includes a date, value, expected mean, z-score, and direction.
- Type exit() to leave the Django shell.
Getting an ImportError?
Make sure you saved services.py inside backend/analytics/ (not inside backend/config/). The Docker volume mount syncs your local files into the container automatically.
If you see a ModuleNotFoundError for numpy, your container may need rebuilding. Run docker compose up --build -d and try the shell command again.
Help me debug an ImportError in my analytics services file.
Build the churn prediction scoring system
Churn prediction identifies accounts that are likely to cancel. Instead of a machine learning model, you'll use a rule-based scoring system that adds risk points based on observable signals: inactivity, declining usage, and recent plan downgrades.
- In backend/analytics/services.py, add the predict_churn function below the detect_anomalies function:
def predict_churn():
accounts = Account.objects.all()
at_risk = []
today = date.today()
for account in accounts:
score = 0
reasons = []
# Signal 1: Days since last login
days_inactive = (today - account.last_active).days
if days_inactive > 14:
score += 40
reasons.append(f"Inactive for {days_inactive} days")
elif days_inactive > 7:
score += 20
reasons.append(f"Low activity ({days_inactive} days since last login)")
# Signal 2: Declining usage trend over 14 days
recent_activity = ActivityLog.objects.filter(
account=account, date__gte=today - timedelta(days=14)
).order_by("date")
if recent_activity.count() >= 7:
first_half = recent_activity[: recent_activity.count() // 2]
second_half = recent_activity[recent_activity.count() // 2 :]
avg_first = sum(a.logins for a in first_half) / max(len(first_half), 1)
avg_second = sum(a.logins for a in second_half) / max(len(second_half), 1)
if avg_second < avg_first * 0.5:
score += 30
reasons.append("Usage declining over past 2 weeks")
# Signal 3: Recent plan downgrade
recent_downgrades = SubscriptionEvent.objects.filter(
account=account, event_type="downgrade", date__gte=today - timedelta(days=30)
)
if recent_downgrades.exists():
score += 30
reasons.append("Recent plan downgrade")
# Only flag accounts that cross the risk threshold
if score >= 40:
at_risk.append(
{
"account_id": account.id,
"company_name": account.company_name,
"plan": account.plan,
"mrr": float(account.mrr),
"risk_score": min(score, 100),
"reasons": reasons,
}
)
return sorted(at_risk, key=lambda x: x["risk_score"], reverse=True)
How does the scoring work?
Each account starts at 0 and accumulates risk points from three signals. Being inactive for over 14 days adds 40 points. A declining login trend (second week has less than half the logins of the first week) adds 30 points. A recent plan downgrade adds another 30 points.
Only accounts scoring 40 or above are returned. The results are sorted highest-risk first, giving the LLM the most urgent accounts to explain.
- Save services.py.
- Test the churn predictor by opening the Django shell again:
docker compose exec backend python manage.py shell
- Inside the shell, run:
from analytics.services import predict_churn
risks = predict_churn()
print(f"{len(risks)} accounts at risk")
if risks:
print(risks[0])
- You should see a list of at-risk accounts, each with a company name, plan, MRR value, risk score, and reasons. The accounts with the highest scores appear first.
- Type exit() to leave the shell.
Seeing 0 accounts at risk?
This can happen if your seed data has all accounts with recent last_active dates. Check that the seed command ran successfully in the previous step. You can verify by running Account.objects.count() in the Django shell to confirm accounts exist.
Help me debug why predict_churn returns no at-risk accounts.
Implement revenue forecasting
The final analytics function projects revenue 30 days into the future using a weighted moving average. Recent data points get higher weight than older ones, which makes the forecast responsive to recent trends.
- In backend/analytics/services.py, add the forecast_revenue function below predict_churn:
def forecast_revenue(days_forward=30):
# Pull the last 90 days of revenue data
end_date = date.today()
start_date = end_date - timedelta(days=90)
revenue_data = list(
RevenueDaily.objects.filter(date__gte=start_date, date__lte=end_date)
.values_list("date", "mrr_total")
.order_by("date")
)
if len(revenue_data) < 30:
return {"forecast": [], "trend": "insufficient_data"}
values = [float(r[1]) for r in revenue_data]
# Create weights that emphasize recent data points
weights = np.linspace(0.5, 1.5, len(values))
weights = weights / weights.sum()
weighted_avg = np.average(values, weights=weights)
# Calculate the recent trend by comparing last 7 days vs prior 23 days
recent_trend = (np.mean(values[-7:]) - np.mean(values[-30:-7])) / max(
np.mean(values[-30:-7]), 1
)
# Project forward with confidence intervals based on standard deviation
std_dev = np.std(values[-30:])
forecast = []
for i in range(1, days_forward + 1):
projected = weighted_avg * (1 + recent_trend * i / 30)
forecast.append(
{
"date": (end_date + timedelta(days=i)).isoformat(),
"projected_mrr": round(projected, 2),
"upper_bound": round(projected + std_dev, 2),
"lower_bound": round(projected - std_dev, 2),
}
)
# Classify the overall trend direction
trend = "growing" if recent_trend > 0.01 else "declining" if recent_trend < -0.01 else "stable"
return {"forecast": forecast, "trend": trend, "trend_pct": round(recent_trend * 100, 2)}
What does this code do?
The function pulls 90 days of revenue history and builds a weighted average where recent values count more. It then measures the short-term trend by comparing the last 7 days against the prior 23 days.
Each projected day applies the trend incrementally, so the forecast curves rather than jumping. The upper and lower bounds give a confidence interval based on the standard deviation of recent data. Finally, the trend is classified as growing, declining, or stable.
- Save services.py.
- Test the forecasting function in the Django shell:
docker compose exec backend python manage.py shell
- Inside the shell, run:
from analytics.services import forecast_revenue
forecast = forecast_revenue()
print(f"Trend: {forecast['trend']} ({forecast.get('trend_pct', 0)}%)")
print(f"Forecast points: {len(forecast['forecast'])}")
if forecast['forecast']:
print(forecast['forecast'][0])
- You should see the trend classification (growing, declining, or stable), the trend percentage, and 30 forecast data points. Each point includes a date, projected MRR, and upper/lower bounds.
- Type exit() to leave the shell.
Seeing insufficient_data?
The function needs at least 30 days of RevenueDaily records in the database. Confirm your seed data covers enough dates by running RevenueDaily.objects.count() in the Django shell. If the count is low, re-run your seed management command.
Help me debug why forecast_revenue returns insufficient_data.
✔️ Awesome, I've got everything!
Great. Double check you've saved backend/analytics/services.py with all three functions.
ⓧ I'd like to double check the full code
Here is the complete backend/analytics/services.py file:
import numpy as np
from datetime import date, timedelta
from django.db.models import Avg
from .models import Account, ActivityLog, RevenueDaily, SubscriptionEvent
def detect_anomalies(days=30):
end_date = date.today()
start_date = end_date - timedelta(days=days + 30)
revenue_data = list(
RevenueDaily.objects.filter(date__gte=start_date, date__lte=end_date)
.values_list("date", "mrr_total")
.order_by("date")
)
if len(revenue_data) < 30:
return []
values = [float(r[1]) for r in revenue_data]
dates = [r[0] for r in revenue_data]
anomalies = []
for i in range(30, len(values)):
window = values[i - 30 : i]
mean = np.mean(window)
std = np.std(window)
if std == 0:
continue
z_score = (values[i] - mean) / std
if abs(z_score) > 2:
anomalies.append(
{
"date": dates[i].isoformat(),
"value": values[i],
"expected": round(mean, 2),
"z_score": round(z_score, 2),
"direction": "spike" if z_score > 0 else "drop",
}
)
return anomalies
def predict_churn():
accounts = Account.objects.all()
at_risk = []
today = date.today()
for account in accounts:
score = 0
reasons = []
days_inactive = (today - account.last_active).days
if days_inactive > 14:
score += 40
reasons.append(f"Inactive for {days_inactive} days")
elif days_inactive > 7:
score += 20
reasons.append(f"Low activity ({days_inactive} days since last login)")
recent_activity = ActivityLog.objects.filter(
account=account, date__gte=today - timedelta(days=14)
).order_by("date")
if recent_activity.count() >= 7:
first_half = recent_activity[: recent_activity.count() // 2]
second_half = recent_activity[recent_activity.count() // 2 :]
avg_first = sum(a.logins for a in first_half) / max(len(first_half), 1)
avg_second = sum(a.logins for a in second_half) / max(len(second_half), 1)
if avg_second < avg_first * 0.5:
score += 30
reasons.append("Usage declining over past 2 weeks")
recent_downgrades = SubscriptionEvent.objects.filter(
account=account, event_type="downgrade", date__gte=today - timedelta(days=30)
)
if recent_downgrades.exists():
score += 30
reasons.append("Recent plan downgrade")
if score >= 40:
at_risk.append(
{
"account_id": account.id,
"company_name": account.company_name,
"plan": account.plan,
"mrr": float(account.mrr),
"risk_score": min(score, 100),
"reasons": reasons,
}
)
return sorted(at_risk, key=lambda x: x["risk_score"], reverse=True)
def forecast_revenue(days_forward=30):
end_date = date.today()
start_date = end_date - timedelta(days=90)
revenue_data = list(
RevenueDaily.objects.filter(date__gte=start_date, date__lte=end_date)
.values_list("date", "mrr_total")
.order_by("date")
)
if len(revenue_data) < 30:
return {"forecast": [], "trend": "insufficient_data"}
values = [float(r[1]) for r in revenue_data]
weights = np.linspace(0.5, 1.5, len(values))
weights = weights / weights.sum()
weighted_avg = np.average(values, weights=weights)
recent_trend = (np.mean(values[-7:]) - np.mean(values[-30:-7])) / max(
np.mean(values[-30:-7]), 1
)
std_dev = np.std(values[-30:])
forecast = []
for i in range(1, days_forward + 1):
projected = weighted_avg * (1 + recent_trend * i / 30)
forecast.append(
{
"date": (end_date + timedelta(days=i)).isoformat(),
"projected_mrr": round(projected, 2),
"upper_bound": round(projected + std_dev, 2),
"lower_bound": round(projected - std_dev, 2),
}
)
trend = "growing" if recent_trend > 0.01 else "declining" if recent_trend < -0.01 else "stable"
return {"forecast": forecast, "trend": trend, "trend_pct": round(recent_trend * 100, 2)}
Your analytics engine is complete. You have three functions that turn raw database records into structured intelligence. Next up, you'll connect these functions to the Groq LLM so it can translate the numbers into plain-English insight cards.
Integrate the Groq LLM for AI Insights
Your analytics engine can detect anomalies, score churn risk, and forecast revenue. These are powerful signals, but raw numbers alone don't tell a story that a product manager can act on.
In this step, you'll connect to the Groq LLM API to transform those computed analytics into natural language insight cards. The LLM interprets pre-computed results and explains what's happening in plain English.
In this step, get ready to:
- Create an AI insights service that packages analytics data for the LLM.
- Build a prompt engineering layer that instructs the LLM to return structured insight cards.
- Wire up DRF API endpoints that serve metrics and AI-generated insights.
Create the insights service
The groq package is already in your requirements.txt from the setup step, so it's installed inside your Docker container. Now you'll create a new module that takes the analytics results and structures them for the LLM to interpret.
- Create a new file backend/analytics/insights.py and add the following code:
import os
import json
from groq import Groq
def generate_insights(anomalies, churn_risks, forecast):
# Initialize the Groq client with the API key from environment
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
# Build a condensed JSON summary of analytics for the LLM
analytics_summary = json.dumps(
{
"anomalies": anomalies[:5],
"churn_risks": churn_risks[:10],
"forecast": {
"trend": forecast["trend"],
"trend_pct": forecast.get("trend_pct", 0),
"next_30_days": forecast["forecast"][:5] if forecast["forecast"] else [],
},
},
indent=2,
)
What does this code do?
The generate_insights function accepts the raw analytics results from your services module. It trims them down (top 5 anomalies, top 10 churn risks, first 5 forecast points) to keep the prompt concise and avoid exceeding token limits.
The Groq client reads GROQ_API_KEY from the environment variable you set in docker-compose.yml during setup.
Build the prompt engineering layer
The prompt engineering layer tells the LLM exactly what format to respond in. A precise system prompt ensures you get structured JSON back instead of freeform text.
- In backend/analytics/insights.py, add the following code directly below the analytics_summary variable (still inside the generate_insights function):
system_prompt = """You are a SaaS analytics AI assistant. Given the following analytics data, generate exactly 3-5 insight cards. Each insight must be a JSON object with these fields:
- "severity": one of "critical", "warning", or "info"
- "title": a short headline (max 10 words)
- "description": 1-2 sentences explaining the insight and its business impact
- "action": a specific recommended action
Respond ONLY with a valid JSON array of insight objects. No markdown, no explanation."""
# Send analytics data to Groq and parse the structured response
chat_completion = client.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Here is the current analytics data:\n{analytics_summary}"},
],
model="llama-3.3-70b-versatile",
temperature=0.3,
max_tokens=1024,
)
# Extract and parse the JSON array from the LLM response
response_text = chat_completion.choices[0].message.content
insights = json.loads(response_text)
return insights
What does this prompt do?
- The system_prompt constrains the LLM to respond with a JSON array only. No markdown, no extra explanation. This makes the response machine-parseable.
- Each insight card must include a severity level, a short title, a description, and an action. This gives the frontend everything it needs to render color-coded cards.
- Setting temperature=0.3 keeps the output deterministic and focused. Higher temperatures produce more creative but less predictable responses.
- The model llama-3.3-70b-versatile is a fast, capable model on Groq's free tier with 30 requests per minute.
- Save backend/analytics/insights.py.
Seeing an indentation error?
The system_prompt and everything below it must be indented one level inside the generate_insights function. Make sure the code from both chunks is inside the same function body with consistent 4-space indentation.
Help me debug my indentation error
✔️ Awesome, I've got everything!
Great. Make sure you've saved insights.py with both code sections inside the single generate_insights function.
ⓧ I'd like to double check the full code
import os
import json
from groq import Groq
def generate_insights(anomalies, churn_risks, forecast):
client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
analytics_summary = json.dumps(
{
"anomalies": anomalies[:5],
"churn_risks": churn_risks[:10],
"forecast": {
"trend": forecast["trend"],
"trend_pct": forecast.get("trend_pct", 0),
"next_30_days": forecast["forecast"][:5] if forecast["forecast"] else [],
},
},
indent=2,
)
system_prompt = """You are a SaaS analytics AI assistant. Given the following analytics data, generate exactly 3-5 insight cards. Each insight must be a JSON object with these fields:
- "severity": one of "critical", "warning", or "info"
- "title": a short headline (max 10 words)
- "description": 1-2 sentences explaining the insight and its business impact
- "action": a specific recommended action
Respond ONLY with a valid JSON array of insight objects. No markdown, no explanation."""
chat_completion = client.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Here is the current analytics data:\n{analytics_summary}"},
],
model="llama-3.3-70b-versatile",
temperature=0.3,
max_tokens=1024,
)
response_text = chat_completion.choices[0].message.content
insights = json.loads(response_text)
return insights
Create the DRF API endpoints
The frontend needs REST API endpoints to fetch metrics and AI insights. You'll create three views that serve the summary KPIs, time-series chart data, and the full AI insights pipeline.
- Create a new file backend/analytics/views.py and add the metrics views:
from rest_framework.decorators import api_view
from rest_framework.response import Response
from datetime import date, timedelta
from .models import RevenueDaily, Account
from .services import detect_anomalies, predict_churn, forecast_revenue
from .insights import generate_insights
@api_view(["GET"])
def metrics_summary(request):
# Get the latest and 30-day-ago revenue entries for growth calculation
latest = RevenueDaily.objects.order_by("-date").first()
prev_30 = RevenueDaily.objects.filter(
date__lte=date.today() - timedelta(days=30)
).order_by("-date").first()
mrr_growth = 0
if latest and prev_30 and prev_30.mrr_total > 0:
mrr_growth = round(
((float(latest.mrr_total) - float(prev_30.mrr_total)) / float(prev_30.mrr_total)) * 100, 1
)
# Calculate churn rate as percentage of at-risk accounts
total_accounts = Account.objects.count()
churn_risks = predict_churn()
churn_rate = round((len(churn_risks) / max(total_accounts, 1)) * 100, 1)
return Response(
{
"mrr": float(latest.mrr_total) if latest else 0,
"active_accounts": latest.active_accounts if latest else 0,
"churn_rate": churn_rate,
"mrr_growth": mrr_growth,
}
)
@api_view(["GET"])
def metrics_timeseries(request):
# Return daily revenue data for the requested time window
days = int(request.query_params.get("days", 30))
start_date = date.today() - timedelta(days=days)
data = RevenueDaily.objects.filter(date__gte=start_date).order_by("date")
return Response(
[
{
"date": entry.date.isoformat(),
"mrr": float(entry.mrr_total),
"active_accounts": entry.active_accounts,
"new_revenue": float(entry.new_revenue),
"churned_revenue": float(entry.churned_revenue),
}
for entry in data
]
)
What do these views do?
- The metrics_summary view computes the current MRR, active accounts, churn rate, and 30-day MRR growth. It returns a single JSON object with dashboard KPIs.
- The metrics_timeseries view returns daily revenue data for a configurable time window (defaults to 30 days). This powers the line charts on the frontend.
- Add the insights view below the metrics_timeseries function in the same file:
@api_view(["GET"])
def insights_view(request):
# Run the full analytics pipeline
anomalies = detect_anomalies()
churn_risks = predict_churn()
forecast = forecast_revenue()
# Send computed analytics to the LLM for insight generation
insights = generate_insights(anomalies, churn_risks, forecast)
return Response(
{
"insights": insights,
"metadata": {
"anomalies_detected": len(anomalies),
"accounts_at_risk": len(churn_risks),
"revenue_trend": forecast["trend"],
},
}
)
What does insights_view do?
This is the orchestration endpoint. It runs all three analytics functions (anomaly detection, churn prediction, revenue forecasting), sends the results to the Groq LLM via generate_insights, and returns both the AI-generated insight cards and metadata about what was analyzed.
- Save backend/analytics/views.py.
✔️ Awesome, I've got everything!
Great. Make sure all three view functions are in the same file with the imports at the top.
ⓧ I'd like to double check the full code
from rest_framework.decorators import api_view
from rest_framework.response import Response
from datetime import date, timedelta
from .models import RevenueDaily, Account
from .services import detect_anomalies, predict_churn, forecast_revenue
from .insights import generate_insights
@api_view(["GET"])
def metrics_summary(request):
latest = RevenueDaily.objects.order_by("-date").first()
prev_30 = RevenueDaily.objects.filter(
date__lte=date.today() - timedelta(days=30)
).order_by("-date").first()
mrr_growth = 0
if latest and prev_30 and prev_30.mrr_total > 0:
mrr_growth = round(
((float(latest.mrr_total) - float(prev_30.mrr_total)) / float(prev_30.mrr_total)) * 100, 1
)
total_accounts = Account.objects.count()
churn_risks = predict_churn()
churn_rate = round((len(churn_risks) / max(total_accounts, 1)) * 100, 1)
return Response(
{
"mrr": float(latest.mrr_total) if latest else 0,
"active_accounts": latest.active_accounts if latest else 0,
"churn_rate": churn_rate,
"mrr_growth": mrr_growth,
}
)
@api_view(["GET"])
def metrics_timeseries(request):
days = int(request.query_params.get("days", 30))
start_date = date.today() - timedelta(days=days)
data = RevenueDaily.objects.filter(date__gte=start_date).order_by("date")
return Response(
[
{
"date": entry.date.isoformat(),
"mrr": float(entry.mrr_total),
"active_accounts": entry.active_accounts,
"new_revenue": float(entry.new_revenue),
"churned_revenue": float(entry.churned_revenue),
}
for entry in data
]
)
@api_view(["GET"])
def insights_view(request):
anomalies = detect_anomalies()
churn_risks = predict_churn()
forecast = forecast_revenue()
insights = generate_insights(anomalies, churn_risks, forecast)
return Response(
{
"insights": insights,
"metadata": {
"anomalies_detected": len(anomalies),
"accounts_at_risk": len(churn_risks),
"revenue_trend": forecast["trend"],
},
}
)
Now connect these views to URL routes so the frontend can reach them.
- Create the file backend/analytics/urls.py with the following code:
from django.urls import path
from . import views
# Map URL paths to their corresponding view functions
urlpatterns = [
path("metrics/summary/", views.metrics_summary, name="metrics-summary"),
path("metrics/timeseries/", views.metrics_timeseries, name="metrics-timeseries"),
path("insights/", views.insights_view, name="insights"),
]
What routes does this create?
- GET /api/metrics/summary/ returns the KPI summary (MRR, active accounts, churn rate, growth).
- GET /api/metrics/timeseries/ returns daily revenue data for charts.
- GET /api/insights/ runs the full AI pipeline and returns insight cards.
The config/urls.py file already includes analytics.urls under the /api/ prefix, so these routes are ready to use.
- Save backend/analytics/urls.py.
- Restart the backend container to pick up the new files by running:
docker compose restart backend
- Test the metrics summary endpoint by running:
curl http://localhost:8000/api/metrics/summary/
- You should see a JSON response with mrr, active_accounts, churn_rate, and mrr_growth values.
- Test the AI insights endpoint by running:
curl http://localhost:8000/api/insights/
- You should see a JSON response containing an insights array with 3-5 objects (each having severity, title, description, and action fields) plus a metadata object.
Getting an authentication error?
This means your GROQ_API_KEY environment variable is not reaching the container. Check that your .env file in the project root contains your key, and that docker-compose.yml passes it via GROQ_API_KEY=${GROQ_API_KEY}.
After updating the key, run docker compose down followed by docker compose up -d to restart with the new environment.
Help me debug my Groq API key setup
Seeing a 500 error or JSON parse error?
If the LLM returns markdown-wrapped JSON (e.g., with backtick fences), the json.loads call will fail. The system prompt explicitly says "No markdown, no explanation" to prevent this. If it still happens, check that you copied the system_prompt string exactly as shown.
Help me fix my 500 error on the insights endpoint
Your API layer is complete. The backend can now serve KPI summaries, time-series data, and AI-generated insight cards. Next up, you'll build the React frontend to display all of this in a polished dashboard.
Build the React Dashboard
Your backend is complete. It computes anomalies, predicts churn, forecasts revenue, and generates AI insight cards through the Groq API.
Now you need a way to show all of that data to users. In this step, you'll build the React frontend that fetches from all three API endpoints and renders the results as a polished dashboard with KPI cards, a revenue chart, and AI-powered insight cards.
In this step, get ready to:
- Set up KPI summary cards displaying MRR, churn rate, and growth.
- Add a Recharts line chart for revenue trends.
- Build AI insight cards with severity-based color coding.
Set up the dashboard layout with KPI summary cards
The dashboard needs a charting library to visualize revenue data over time. Recharts is a React charting library that uses composable components like LineChart and XAxis to build interactive charts.
- Install Recharts inside the frontend container by running this command:
docker compose exec frontend npm install recharts react-is
Why install react-is separately?
Recharts requires react-is as a peer dependency. Installing it explicitly ensures the versions align with your project's React version.
- Verify the install succeeded. You should see packages added to package.json in the terminal output.
Install command failing?
Make sure your Docker Compose stack is running. If the frontend container is stopped, start it with docker compose up -d first.
Help me troubleshoot the Recharts install
Now you'll create the KPI cards component. This component receives summary data (MRR, active accounts, churn rate, growth) and renders four metric cards with trend indicators.
- Create a new file at frontend/src/components/KPICards.jsx and add this code:
function KPICards({ data }) {
const cards = [
{ label: "Monthly Recurring Revenue", value: `$${data.mrr.toLocaleString()}`, trend: data.mrr_growth },
{ label: "Active Accounts", value: data.active_accounts, trend: null },
{ label: "Churn Rate", value: `${data.churn_rate}%`, trend: null },
{ label: "MRR Growth", value: `${data.mrr_growth}%`, trend: data.mrr_growth },
];
return (
<div className="kpi-grid">
{cards.map((card) => (
<div key={card.label} className="kpi-card">
<span className="kpi-label">{card.label}</span>
<span className="kpi-value">{card.value}</span>
{card.trend !== null && (
<span className={`kpi-trend ${card.trend >= 0 ? "positive" : "negative"}`}>
{card.trend >= 0 ? "+" : ""}{card.trend}%
</span>
)}
</div>
))}
</div>
);
}
export default KPICards;
What does this code do?
The component defines an array of card objects, each with a label, formatted value, and optional trend number. It maps over them to render a grid of KPI cards.
When a card has a trend value, it renders a colored span showing positive (green) or negative (red) growth. Cards without trends (like Active Accounts) skip the trend indicator entirely.
- Save KPICards.jsx.
- You won't see a change yet. The next chunk connects this component to the main app layout.
Next, you'll build the main App.jsx file. This is the dashboard's entry point. It fetches data from all three backend endpoints in parallel, manages loading state, and renders the full layout.
- Replace the contents of frontend/src/App.jsx with this code:
import { useState, useEffect } from "react";
import KPICards from "./components/KPICards";
import RevenueChart from "./components/RevenueChart";
import InsightCards from "./components/InsightCards";
import "./App.css";
const API_BASE = "http://localhost:8000/api";
function App() {
const [summary, setSummary] = useState(null);
const [timeseries, setTimeseries] = useState([]);
const [insights, setInsights] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [summaryRes, timeseriesRes, insightsRes] = await Promise.all([
fetch(`${API_BASE}/metrics/summary/`),
fetch(`${API_BASE}/metrics/timeseries/?days=30`),
fetch(`${API_BASE}/insights/`),
]);
setSummary(await summaryRes.json());
setTimeseries(await timeseriesRes.json());
setInsights(await insightsRes.json());
} catch (error) {
console.error("Failed to fetch data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return <div className="loading">Loading dashboard...</div>;
}
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>SaaS Analytics Dashboard</h1>
<span className="ai-badge">AI-Powered Insights</span>
</header>
{summary && <KPICards data={summary} />}
<div className="dashboard-grid">
<div className="chart-section">
<h2>Revenue Trend</h2>
<RevenueChart data={timeseries} />
</div>
<div className="insights-section">
<h2>AI Insights</h2>
{insights && <InsightCards data={insights} />}
</div>
</div>
</div>
);
}
export default App;
What does this code do?
The useEffect hook fires on mount and calls all three API endpoints in parallel using Promise.all. This avoids sequential requests and loads the dashboard faster.
While data is loading, the component shows a loading message. Once all responses arrive, it renders the header, KPI cards, revenue chart, and insight cards in a two-column grid layout.
- Save App.jsx.
- The app will show errors in the browser because RevenueChart and InsightCards don't exist yet. You'll create them in the next two substeps.
Finally, you need the CSS that styles the entire dashboard. This single stylesheet handles the KPI grid, chart section, insight cards, and severity badges.
- Replace the contents of frontend/src/App.css with this code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 1.2rem;
color: #64748b;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.dashboard-header h1 {
font-size: 1.5rem;
}
.ai-badge {
background: #6366f1;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.kpi-card {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kpi-label {
font-size: 0.8rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kpi-value {
font-size: 1.5rem;
font-weight: 700;
}
.kpi-trend {
font-size: 0.85rem;
font-weight: 600;
}
.kpi-trend.positive {
color: #10b981;
}
.kpi-trend.negative {
color: #ef4444;
}
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.chart-section,
.insights-section {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.chart-section h2,
.insights-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #374151;
}
.insight-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.insight-card {
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.insight-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.insight-header h3 {
font-size: 0.85rem;
font-weight: 600;
}
.severity-badge {
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-weight: 700;
color: white;
}
.severity-badge.critical {
background: #ef4444;
}
.severity-badge.warning {
background: #f59e0b;
}
.severity-badge.info {
background: #10b981;
}
.insight-description {
font-size: 0.8rem;
color: #4b5563;
margin-bottom: 0.5rem;
}
.insight-action {
font-size: 0.75rem;
color: #6366f1;
font-weight: 500;
}
.insight-metadata {
display: flex;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
font-size: 0.7rem;
color: #9ca3af;
}
What does this stylesheet do?
The CSS sets up a clean dashboard layout with a 4-column KPI grid at the top and a 2:1 grid below (chart on the left, insights on the right). Each card gets white backgrounds with subtle shadows.
The severity badge classes (.critical, .warning, .info) map to red, amber, and green backgrounds for the AI insight cards you'll build shortly.
- Save App.css.
Add the revenue chart component
The revenue chart visualizes MRR over time as a line chart. Recharts provides composable components that you nest inside a LineChart wrapper to build the visualization declaratively.
- Create a new file at frontend/src/components/RevenueChart.jsx and add this code:
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
function RevenueChart({ data }) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={(d) => d.slice(5)} />
<YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip formatter={(value) => [`$${value.toLocaleString()}`, "MRR"]} />
<Line type="monotone" dataKey="mrr" stroke="#6366f1" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
export default RevenueChart;
What does this code do?
ResponsiveContainer makes the chart fill its parent's width and sets a fixed 300px height. Inside, LineChart receives the time-series data array from the backend.
The XAxis strips the year prefix from dates (showing only month-day). The YAxis formats values as shorthand dollars (e.g. $45k). The Line component draws a smooth purple line tracking the mrr field from each data point.
- Save RevenueChart.jsx.
- The dashboard still won't render because the InsightCards component is missing. You'll create it next.
Build the AI insight cards component
The insight cards are the standout feature of this dashboard. They display AI-generated analysis from the Groq LLM, color-coded by severity so users can immediately spot critical issues.
- Create a new file at frontend/src/components/InsightCards.jsx and add this code:
function InsightCards({ data }) {
const severityColors = {
critical: "#ef4444",
warning: "#f59e0b",
info: "#10b981",
};
return (
<div className="insight-cards">
{data.insights.map((insight, index) => (
<div
key={index}
className="insight-card"
style={{ borderLeft: `4px solid ${severityColors[insight.severity]}` }}
>
<div className="insight-header">
<span className={`severity-badge ${insight.severity}`}>
{insight.severity.toUpperCase()}
</span>
<h3>{insight.title}</h3>
</div>
<p className="insight-description">{insight.description}</p>
<p className="insight-action">Action: {insight.action}</p>
</div>
))}
<div className="insight-metadata">
<span>{data.metadata.anomalies_detected} anomalies detected</span>
<span>{data.metadata.accounts_at_risk} accounts at risk</span>
<span>Trend: {data.metadata.revenue_trend}</span>
</div>
</div>
);
}
export default InsightCards;
What does this code do?
The component maps over the insights array returned by the /api/insights/ endpoint. Each card gets a colored left border based on its severity (red for critical, amber for warning, green for info).
Below the cards, a metadata footer shows aggregate stats: how many anomalies were detected, how many accounts are at risk, and the overall revenue trend. This gives users a quick summary alongside the AI-generated detail.
- Save InsightCards.jsx.
- Open your browser to http://localhost:5173. You should see the full dashboard with four KPI cards at the top, a purple revenue line chart on the left, and color-coded AI insight cards on the right.
Seeing a blank page or errors?
Check your browser's developer console for import errors. If you see a message about a missing module, confirm all four files are saved in the correct paths: App.jsx, App.css, and the three component files inside frontend/src/components/.
If the KPI cards show zeros or the insight cards don't appear, make sure your backend containers are running and the seed data was loaded in a previous step.
Help me debug my React dashboard
✔️ Awesome, I've got everything!
Great. Double check you've saved all files and your dashboard is rendering at http://localhost:5173.
ⓧ I'd like to double check the full code
Here is the complete code for each frontend file:
function KPICards({ data }) {
const cards = [
{ label: "Monthly Recurring Revenue", value: `$${data.mrr.toLocaleString()}`, trend: data.mrr_growth },
{ label: "Active Accounts", value: data.active_accounts, trend: null },
{ label: "Churn Rate", value: `${data.churn_rate}%`, trend: null },
{ label: "MRR Growth", value: `${data.mrr_growth}%`, trend: data.mrr_growth },
];
return (
<div className="kpi-grid">
{cards.map((card) => (
<div key={card.label} className="kpi-card">
<span className="kpi-label">{card.label}</span>
<span className="kpi-value">{card.value}</span>
{card.trend !== null && (
<span className={`kpi-trend ${card.trend >= 0 ? "positive" : "negative"}`}>
{card.trend >= 0 ? "+" : ""}{card.trend}%
</span>
)}
</div>
))}
</div>
);
}
export default KPICards;
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
function RevenueChart({ data }) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tickFormatter={(d) => d.slice(5)} />
<YAxis tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip formatter={(value) => [`$${value.toLocaleString()}`, "MRR"]} />
<Line type="monotone" dataKey="mrr" stroke="#6366f1" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
export default RevenueChart;
function InsightCards({ data }) {
const severityColors = {
critical: "#ef4444",
warning: "#f59e0b",
info: "#10b981",
};
return (
<div className="insight-cards">
{data.insights.map((insight, index) => (
<div
key={index}
className="insight-card"
style={{ borderLeft: `4px solid ${severityColors[insight.severity]}` }}
>
<div className="insight-header">
<span className={`severity-badge ${insight.severity}`}>
{insight.severity.toUpperCase()}
</span>
<h3>{insight.title}</h3>
</div>
<p className="insight-description">{insight.description}</p>
<p className="insight-action">Action: {insight.action}</p>
</div>
))}
<div className="insight-metadata">
<span>{data.metadata.anomalies_detected} anomalies detected</span>
<span>{data.metadata.accounts_at_risk} accounts at risk</span>
<span>Trend: {data.metadata.revenue_trend}</span>
</div>
</div>
);
}
export default InsightCards;
import { useState, useEffect } from "react";
import KPICards from "./components/KPICards";
import RevenueChart from "./components/RevenueChart";
import InsightCards from "./components/InsightCards";
import "./App.css";
const API_BASE = "http://localhost:8000/api";
function App() {
const [summary, setSummary] = useState(null);
const [timeseries, setTimeseries] = useState([]);
const [insights, setInsights] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchData() {
try {
const [summaryRes, timeseriesRes, insightsRes] = await Promise.all([
fetch(`${API_BASE}/metrics/summary/`),
fetch(`${API_BASE}/metrics/timeseries/?days=30`),
fetch(`${API_BASE}/insights/`),
]);
setSummary(await summaryRes.json());
setTimeseries(await timeseriesRes.json());
setInsights(await insightsRes.json());
} catch (error) {
console.error("Failed to fetch data:", error);
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return <div className="loading">Loading dashboard...</div>;
}
return (
<div className="dashboard">
<header className="dashboard-header">
<h1>SaaS Analytics Dashboard</h1>
<span className="ai-badge">AI-Powered Insights</span>
</header>
{summary && <KPICards data={summary} />}
<div className="dashboard-grid">
<div className="chart-section">
<h2>Revenue Trend</h2>
<RevenueChart data={timeseries} />
</div>
<div className="insights-section">
<h2>AI Insights</h2>
{insights && <InsightCards data={insights} />}
</div>
</div>
</div>
);
}
export default App;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f8fafc;
color: #1e293b;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 1.2rem;
color: #64748b;
}
.dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.dashboard-header h1 {
font-size: 1.5rem;
}
.ai-badge {
background: #6366f1;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.kpi-card {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.kpi-label {
font-size: 0.8rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kpi-value {
font-size: 1.5rem;
font-weight: 700;
}
.kpi-trend {
font-size: 0.85rem;
font-weight: 600;
}
.kpi-trend.positive {
color: #10b981;
}
.kpi-trend.negative {
color: #ef4444;
}
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.chart-section,
.insights-section {
background: white;
padding: 1.5rem;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.chart-section h2,
.insights-section h2 {
font-size: 1rem;
margin-bottom: 1rem;
color: #374151;
}
.insight-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.insight-card {
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.insight-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.insight-header h3 {
font-size: 0.85rem;
font-weight: 600;
}
.severity-badge {
font-size: 0.6rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-weight: 700;
color: white;
}
.severity-badge.critical {
background: #ef4444;
}
.severity-badge.warning {
background: #f59e0b;
}
.severity-badge.info {
background: #10b981;
}
.insight-description {
font-size: 0.8rem;
color: #4b5563;
margin-bottom: 0.5rem;
}
.insight-action {
font-size: 0.75rem;
color: #6366f1;
font-weight: 500;
}
.insight-metadata {
display: flex;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
font-size: 0.7rem;
color: #9ca3af;
}
Your dashboard is live. Next up, you'll tackle the Secret Mission and add a trend comparison feature with AI-powered comparative analysis.
Secret mission
Add Trend Comparison with AI Analysis
Your dashboard already surfaces real-time AI insights. But what if users could compare two time periods and get an AI-generated narrative explaining what changed? That is exactly what production analytics tools offer.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally via Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (PostgreSQL, Django, React dev server) running locally.
- PostgreSQL volume (pgdata) storing your mock SaaS data.
- Local project files (docker-compose.yml, backend/, frontend/).
Keep everything running
No action needed. Choose this if you are still actively building on your dashboard or experimenting with the Groq API prompts.
- Your Docker Compose stack continues running in the background.
- The containers use system memory and CPU but incur no cloud costs.
- Your Groq API key remains active on the free tier with no charges.
Pause - I'll come back to this later
Stop the running containers to free up system memory without losing your database data or project files.
- Stop all containers by running this command from the folder containing your docker-compose.yml:
docker compose stop
- When you are ready to resume, start everything again by running:
docker compose start
Your PostgreSQL data and project files remain intact on disk.
Delete - I don't want to use this again
Remove all Docker containers, the PostgreSQL volume, and your local project files for a complete cleanup.
- Remove all containers and the PostgreSQL volume by running this command from the folder containing your docker-compose.yml:
docker compose down -v
What does this command do?
The -v flag tells Docker Compose to remove the named volumes (pgdata) along with the containers and networks. Without it, your database data would persist even after the containers are gone.
- Delete your local project files by running:
macOS
cd ~/Desktop
rm -rf saas-analytics-dashboard
Windows
cd ~\Desktop
rmdir /s /q saas-analytics-dashboard
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see your project folder in the output.
Nice Work!
Nice Work!
Nice work! You just built a full-stack AI-powered SaaS analytics dashboard using React and Django REST Framework.
You've learned how to:
- Implemented statistical anomaly detection, churn risk scoring, and revenue forecasting in Python using z-score analysis and weighted moving averages.
- Integrated the Groq LLM API to generate natural language insight cards from raw analytics data using structured prompt engineering.
- Containerized the entire application with Docker Compose running Django, PostgreSQL, and a React dev server together.
- Secret Mission: Added an AI-powered trend comparison feature that generates comparative analysis between two time periods.
Ready to quiz yourself?