diff --git a/apps/api/src/controllers/Analytics.ts b/apps/api/src/controllers/Analytics.ts index bd932db..a170417 100644 --- a/apps/api/src/controllers/Analytics.ts +++ b/apps/api/src/controllers/Analytics.ts @@ -21,7 +21,7 @@ export class Analytics { @Get('timeseries') @Middleware([requireAuth]) @CatchAsync - public async getTimeSeries(req: Request, res: Response, next: NextFunction) { + public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; @@ -43,7 +43,7 @@ export class Analytics { @Get('top-campaigns') @Middleware([requireAuth]) @CatchAsync - public async getTopCampaigns(req: Request, res: Response, next: NextFunction) { + public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; @@ -53,4 +53,52 @@ export class Analytics { return res.status(200).json(topCampaigns); } + + /** + * GET /analytics/campaign-stats + * Get campaign overview statistics + * + * Query params: + * - startDate: ISO date string (defaults to 30 days ago) + * - endDate: ISO date string (defaults to now) + * + * Returns aggregate stats: total campaigns, active, completed, average rates + */ + @Get('campaign-stats') + @Middleware([requireAuth]) + @CatchAsync + public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + const campaignStats = await AnalyticsService.getCampaignStats(auth.projectId, startDate, endDate); + + return res.status(200).json(campaignStats); + } + + /** + * GET /analytics/top-events + * Get most frequently triggered events + * + * Query params: + * - limit: number (default 5, max 20) + * - startDate: ISO date string (defaults to 30 days ago) + * - endDate: ISO date string (defaults to now) + * + * Returns events sorted by frequency with trend data + */ + @Get('top-events') + @Middleware([requireAuth]) + @CatchAsync + public async getTopEvents(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const limit = Math.min(parseInt(req.query.limit as string) || 5, 20); + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + const topEvents = await AnalyticsService.getTopEvents(auth.projectId, limit, startDate, endDate); + + return res.status(200).json(topEvents); + } } diff --git a/apps/api/src/services/AnalyticsService.ts b/apps/api/src/services/AnalyticsService.ts index 3b49526..e226974 100644 --- a/apps/api/src/services/AnalyticsService.ts +++ b/apps/api/src/services/AnalyticsService.ts @@ -165,6 +165,214 @@ export class AnalyticsService { })); } + /** + * Get campaign statistics overview + * Returns aggregate stats for all campaigns in date range + * + * Performance: O(1) with indexed queries + * - Uses aggregation on indexed fields + * - Cached in Redis for 15 minutes + */ + public static async getCampaignStats( + projectId: string, + startDate?: Date, + endDate?: Date, + ): Promise<{ + total: number; + active: number; + completed: number; + averageOpenRate: number; + averageClickRate: number; + }> { + const now = new Date(); + const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000); + const effectiveStartDate = startDate || defaultStartDate; + const effectiveEndDate = endDate || now; + + // Check cache + const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`; + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // Get all campaigns in date range + const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([ + // Total campaigns created in date range + prisma.campaign.count({ + where: { + projectId, + createdAt: { + gte: effectiveStartDate, + lte: effectiveEndDate, + }, + }, + }), + // Active campaigns (not sent yet) + prisma.campaign.count({ + where: { + projectId, + createdAt: { + gte: effectiveStartDate, + lte: effectiveEndDate, + }, + status: { + in: ['DRAFT', 'SCHEDULED'], + }, + }, + }), + // Sent campaigns with stats + prisma.campaign.findMany({ + where: { + projectId, + sentAt: { + gte: effectiveStartDate, + lte: effectiveEndDate, + }, + status: 'SENT', + }, + select: { + sentCount: true, + openedCount: true, + clickedCount: true, + }, + }), + ]); + + // Calculate averages + let totalSent = 0; + let totalOpened = 0; + let totalClicked = 0; + + sentCampaigns.forEach(campaign => { + totalSent += campaign.sentCount || 0; + totalOpened += campaign.openedCount || 0; + totalClicked += campaign.clickedCount || 0; + }); + + const completed = sentCampaigns.length; + const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0; + const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0; + + const stats = { + total: totalCampaigns, + active: activeCampaigns, + completed, + averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal + averageClickRate: Math.round(averageClickRate * 10) / 10, + }; + + // Cache for 15 minutes + await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats)); + + return stats; + } + + /** + * Get top events by frequency with trend data + * + * Performance: O(n log n) where n = number of unique events + * - Groups events and counts occurrences + * - Compares with previous period for trend calculation + * - Cached in Redis for 15 minutes + */ + public static async getTopEvents( + projectId: string, + limit = 5, + startDate?: Date, + endDate?: Date, + ): Promise< + { + name: string; + count: number; + trend: number; + }[] + > { + const now = new Date(); + const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000); + const effectiveStartDate = startDate || defaultStartDate; + const effectiveEndDate = endDate || now; + + // Check cache + const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`; + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // Calculate previous period for trend comparison + const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime(); + const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength); + const previousEndDate = new Date(effectiveStartDate.getTime()); + + // Get current period events + const currentEvents = await prisma.event.groupBy({ + by: ['name'], + where: { + projectId, + createdAt: { + gte: effectiveStartDate, + lte: effectiveEndDate, + }, + }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: 'desc', + }, + }, + take: limit, + }); + + // Get previous period events for trend calculation + const previousEvents = await prisma.event.groupBy({ + by: ['name'], + where: { + projectId, + name: { + in: currentEvents.map(e => e.name), + }, + createdAt: { + gte: previousStartDate, + lte: previousEndDate, + }, + }, + _count: { + id: true, + }, + }); + + // Create map of previous counts + const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id])); + + // Calculate trends + const topEvents = currentEvents.map(event => { + const currentCount = event._count.id; + const previousCount = previousCountMap.get(event.name) || 0; + + // Calculate percentage change + let trend = 0; + if (previousCount > 0) { + trend = Math.round(((currentCount - previousCount) / previousCount) * 100); + } else if (currentCount > 0) { + trend = 100; // New event, 100% increase + } + + return { + name: event.name, + count: currentCount, + trend, + }; + }); + + // Cache for 15 minutes + await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents)); + + return topEvents; + } + /** * Fill in missing dates in time series with zero values * Ensures consistent daily data points even when no emails were sent diff --git a/apps/web/src/pages/analytics/index.tsx b/apps/web/src/pages/analytics/index.tsx index b90a850..bb7f16b 100644 --- a/apps/web/src/pages/analytics/index.tsx +++ b/apps/web/src/pages/analytics/index.tsx @@ -15,35 +15,47 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from '@plunk/ui'; import {DashboardLayout} from '../../components/DashboardLayout'; import {useAnalytics} from '../../lib/hooks/useAnalytics'; -import {AlertCircle, BarChart3, CheckCircle2, Eye, Mail, MousePointerClick, Send, TrendingUp, Zap} from 'lucide-react'; +import useSWR from 'swr'; +import { + Activity, + AlertCircle, + BarChart3, + CheckCircle2, + Eye, + Mail, + Megaphone, + MousePointerClick, + Send, + Zap +} from 'lucide-react'; import {NextSeo} from 'next-seo'; import {useMemo, useState} from 'react'; -import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts'; +import {Area, AreaChart, CartesianGrid, Line, LineChart, XAxis, YAxis} from 'recharts'; // Chart configurations with sleek blue theme -// Chart configurations +// Chart configurations with sleek blue theme const volumeChartConfig = { emails: { label: 'Emails Sent', - color: 'hsl(var(--chart-1))', + color: 'hsl(221.2 83.2% 53.3%)', // Vibrant blue }, opens: { label: 'Opens', - color: 'hsl(var(--chart-2))', + color: 'hsl(142.1 76.2% 36.3%)', // Green }, clicks: { label: 'Clicks', - color: 'hsl(var(--chart-3))', + color: 'hsl(262.1 83.3% 57.8%)', // Purple }, } satisfies ChartConfig; const engagementChartConfig = { openRate: { label: 'Open Rate', - color: 'hsl(var(--chart-2))', + color: 'hsl(221.2 83.2% 53.3%)', // Vibrant blue to match }, } satisfies ChartConfig; @@ -53,12 +65,55 @@ export default function AnalyticsPage() { const {stats, timeSeries, isLoading, error} = useAnalytics({days}); - // Calculate engagement rate - const engagementRate = useMemo(() => { - if (!stats?.totalEmailsSent) return 0; - const engaged = stats.totalEmailsOpened + stats.totalEmailsClicked; - return (engaged / stats.totalEmailsSent) * 100; - }, [stats]); + // Calculate start and end dates for additional API calls + const {startDate, endDate} = useMemo(() => { + const end = new Date(); + const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); + return {startDate: start.toISOString(), endDate: end.toISOString()}; + }, [days]); + + // Fetch campaign stats from API + const {data: campaignStats} = useSWR<{ + total: number; + active: number; + completed: number; + averageOpenRate: number; + averageClickRate: number; + }>(`/analytics/campaign-stats?startDate=${startDate}&endDate=${endDate}`, { + revalidateOnFocus: false, + refreshInterval: 300000, + dedupingInterval: 10000, + }); + + // Fetch top events from API + const {data: topEvents} = useSWR< + { + name: string; + count: number; + trend: number; + }[] + >(`/analytics/top-events?limit=5&startDate=${startDate}&endDate=${endDate}`, { + revalidateOnFocus: false, + refreshInterval: 300000, + dedupingInterval: 10000, + }); + + // Fetch top campaigns from API + const {data: topCampaigns} = useSWR< + { + id: string; + subject: string; + sentCount: number; + openedCount: number; + clickedCount: number; + openRate: number; + clickRate: number; + }[] + >(`/analytics/top-campaigns?limit=10&startDate=${startDate}&endDate=${endDate}`, { + revalidateOnFocus: false, + refreshInterval: 300000, + dedupingInterval: 10000, + }); // Process time series data for charts const chartData = useMemo(() => { @@ -122,13 +177,31 @@ export default function AnalyticsPage() { trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral', }, { - name: 'Engagement', - value: `${engagementRate.toFixed(1)}%`, - icon: TrendingUp, - description: 'Opens + Clicks', + name: 'Active Campaigns', + value: campaignStats?.active?.toLocaleString() || '0', + icon: Megaphone, + description: `${campaignStats?.total || 0} total campaigns`, + color: 'text-indigo-600', + bgColor: 'bg-indigo-100', + trend: campaignStats?.active ? 'positive' : 'neutral', + }, + { + name: 'Workflows', + value: stats?.totalWorkflowsStarted?.toLocaleString() || '0', + icon: Activity, + description: 'Automations triggered', + color: 'text-cyan-600', + bgColor: 'bg-cyan-100', + trend: stats?.totalWorkflowsStarted && stats.totalWorkflowsStarted > 0 ? 'positive' : 'neutral', + }, + { + name: 'Total Events', + value: stats?.totalEvents?.toLocaleString() || '0', + icon: Zap, + description: 'Custom events tracked', color: 'text-orange-600', bgColor: 'bg-orange-100', - trend: engagementRate > 20 ? 'positive' : 'neutral', + trend: stats?.totalEvents && stats.totalEvents > 0 ? 'positive' : 'neutral', }, ]; @@ -137,252 +210,441 @@ export default function AnalyticsPage() {
- {/* Header */} -
-
-

Analytics

-

- Comprehensive insights into your email performance, engagement metrics, and delivery statistics. -

+ {/* Header */} +
+
+

Analytics

+

+ Comprehensive insights into your email performance, engagement metrics, and delivery statistics. +

+
+
+ +
-
- -
-
- {/* Error State */} - {error && ( - - -
- - Failed to load analytics data. Please try again. -
-
-
- )} + {/* Error State */} + {error && ( + + +
+ + Failed to load analytics data. Please try again. +
+
+
+ )} - {/* Stats Grid */} -
- {statsCards.map(stat => { - const Icon = stat.icon; - return ( - - -
- {stat.name} -
- + {/* Stats Grid */} +
+ {statsCards.map(stat => { + const Icon = stat.icon; + return ( + + +
+ {stat.name} +
+ +
+ {isLoading ? '-' : stat.value} +
+ +

{stat.description}

+
+
+ ); + })} +
+ + {/* Email Volume Chart */} + + + Email Volume Trends + Daily email sends, opens, and clicks over the selected period + + + {!hasData ? ( +
+
+ +

No email data yet

+

Send your first email to see analytics here

- {isLoading ? '-' : stat.value} - - -

{stat.description}

-
- - ); - })} -
- - {/* Email Volume Chart */} - - - Email Volume Trends - Daily email sends, opens, and clicks over the selected period - - - {!hasData ? ( -
-
- -

No email data yet

-

Send your first email to see analytics here

-
- ) : ( - - - - - - - - - - - - - - - - - - - - } /> - - - - - - - )} -
-
- - {/* Engagement Rate Chart */} - - - Engagement Rate Trends - Open rate percentage over time - - - {!hasData ? ( -
-
- -

No engagement data

-

- Engagement metrics will appear once emails are opened -

-
-
- ) : ( - - - - - `${value}%`} - /> - } cursor={false} /> - - - - )} -
-
- - {/* Key Insights */} -
- - - Performance Insights - Key metrics and recommendations - - -
-
- -
-
-

Open Rate

-

- {stats?.openRate && stats.openRate > 20 - ? 'Your open rate is above industry average!' - : 'Consider improving subject lines to increase open rates.'} -

-
-
-
-
- -
-
-

Click Rate

-

- {stats?.clickRate && stats.clickRate > 3 - ? 'Great click-through performance!' - : 'Add more compelling calls-to-action to boost clicks.'} -

-
-
-
-
- -
-
-

Engagement

-

- {stats?.totalWorkflowsStarted - ? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started` - : 'Set up workflows to automate your email campaigns.'} -

-
-
+ ) : ( + + + + + + + + + + + + + + + + + + + + { + return value; + }} + /> + } + /> + + + + } verticalAlign="top" height={36} /> + + + )}
+ {/* Engagement Rate Chart */} - Event Activity - Custom events and triggers + Engagement Rate Trends + Open rate percentage over time - -
-
-

{stats?.totalEvents?.toLocaleString() || '0'}

-

Total Events

+ + {!hasData ? ( +
+
+ +

No engagement data

+

+ Engagement metrics will appear once emails are opened +

+
-
- -
-
-
-

- Events triggered by your contacts over the last {days} days. These can trigger workflows and - automations. -

-
+ ) : ( + + + + + `${value}%`} + className="text-muted-foreground" + /> + value} + formatter={(value: any) => [`${value}%`, 'Open Rate']} + /> + } + cursor={{ + stroke: 'hsl(var(--border))', + strokeWidth: 1, + strokeDasharray: '3 3', + }} + /> + + + + )} + + {/* Key Insights */} +
+ + + Performance Insights + Key metrics and recommendations + + +
+
+ +
+
+

Open Rate

+

+ {stats?.openRate && stats.openRate > 20 + ? 'Your open rate is above industry average!' + : 'Consider improving subject lines to increase open rates.'} +

+
+
+
+
+ +
+
+

Click Rate

+

+ {stats?.clickRate && stats.clickRate > 3 + ? 'Great click-through performance!' + : 'Add more compelling calls-to-action to boost clicks.'} +

+
+
+
+
+ +
+
+

Engagement

+

+ {stats?.totalWorkflowsStarted + ? `${stats.totalWorkflowsStarted.toLocaleString()} workflows started` + : 'Set up workflows to automate your email campaigns.'} +

+
+
+
+
+ + + + Event Activity + Custom events and triggers + + +
+
+

{stats?.totalEvents?.toLocaleString() || '0'}

+

Total Events

+
+
+ +
+
+
+

+ Events triggered by your contacts over the last {days} days. These can trigger workflows and + automations. +

+
+
+
+
+ + {/* Campaign Performance */} + {topCampaigns && topCampaigns.length > 0 && ( + + + Campaign Performance + Top performing campaigns in the last {days} days + + +
+ + + + + + + + + + + + + {topCampaigns.map((campaign, idx) => ( + + + + + + + + + ))} + +
CampaignSentOpenedClickedOpen RateClick Rate
+
+
+ {idx + 1} +
+ {campaign.subject} +
+
+ {campaign.sentCount.toLocaleString()} + + {campaign.openedCount.toLocaleString()} + + {campaign.clickedCount.toLocaleString()} + + 30 + ? 'text-green-600' + : campaign.openRate > 20 + ? 'text-blue-600' + : 'text-neutral-600' + }`} + > + {campaign.openRate.toFixed(1)}% + + + 5 + ? 'text-green-600' + : campaign.clickRate > 3 + ? 'text-blue-600' + : 'text-neutral-600' + }`} + > + {campaign.clickRate.toFixed(1)}% + +
+
+
+
+ )} + + {/* Top Events */} + {topEvents && topEvents.length > 0 && ( + + + Top Events + Most frequently triggered events in the last {days} days + + +
+ {topEvents.map((event, index) => ( +
+
+
+ {index + 1} +
+
+

{event.name}

+

{event.count.toLocaleString()} occurrences

+
+
+
+
0 + ? 'bg-green-100 text-green-700' + : event.trend < 0 + ? 'bg-red-100 text-red-700' + : 'bg-neutral-100 text-neutral-700' + }`} + > + {event.trend > 0 ? '+' : ''} + {event.trend}% +
+
+
+ ))} +
+
+
+ )}
-
- + ); } diff --git a/packages/ui/src/components/atoms/Chart.tsx b/packages/ui/src/components/atoms/Chart.tsx index 35f595c..1b38638 100644 --- a/packages/ui/src/components/atoms/Chart.tsx +++ b/packages/ui/src/components/atoms/Chart.tsx @@ -83,13 +83,16 @@ const ChartStyle = ({id, config}: {id: string; config: ChartConfig}) => { return (