diff --git a/frontend/app/api/analytics/cohorts/route.ts b/frontend/app/api/analytics/cohorts/route.ts new file mode 100644 index 0000000..46b7ed2 --- /dev/null +++ b/frontend/app/api/analytics/cohorts/route.ts @@ -0,0 +1,167 @@ +import { NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; + +interface CohortData { + cohort: string; + size: number; + retention: number[]; + periods: string[]; +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const startDate = new Date(searchParams.get('startDate') || ''); + const endDate = new Date(searchParams.get('endDate') || ''); + const period = searchParams.get('period') || 'week'; + const cohortSize = searchParams.get('cohortSize') || 'week'; + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return NextResponse.json( + { error: 'Invalid date range' }, + { status: 400 } + ); + } + + const { db } = await connectToDatabase(); + + // Function to format date based on period + const formatDate = (date: Date, size: 'day' | 'week' | 'month' = 'week'): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + switch (size) { + case 'day': + return `${year}-${month}-${day}`; + case 'week': { + // Get week number + const firstDayOfYear = new Date(date.getFullYear(), 0, 1); + const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000; + const weekNumber = Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); + return `${year}-W${String(weekNumber).padStart(2, '0')}`; + } + case 'month': + default: + return `${year}-${month}`; + } + }; + + // Get all users grouped by cohort + const usersByCohort = await db.collection('analytics_events').aggregate([ + { + $match: { + timestamp: { + $gte: startDate.toISOString(), + $lte: endDate.toISOString(), + }, + }, + }, + { + $group: { + _id: '$userId', + firstSeen: { $min: '$timestamp' }, + events: { + $push: { + name: '$name', + timestamp: '$timestamp', + }, + }, + }, + }, + { + $project: { + cohort: { $dateToString: { format: '%Y-%m-%d', date: { $toDate: '$firstSeen' } } }, + firstSeen: 1, + events: 1, + }, + }, + { + $group: { + _id: '$cohort', + users: { $push: '$$ROOT' }, + size: { $sum: 1 }, + }, + }, + { + $sort: { _id: 1 }, + }, + ]).toArray(); + + // Calculate retention for each cohort + const cohorts: CohortData[] = []; + const allPeriods: Set = new Set(); + + for (const cohort of usersByCohort) { + const cohortDate = new Date(cohort._id); + const cohortData: CohortData = { + cohort: formatDate(cohortDate, cohortSize as 'day' | 'week' | 'month'), + size: cohort.size, + retention: [], + periods: [], + }; + + // Calculate retention for each period + let currentDate = new Date(cohortDate); + let periodIndex = 0; + + while (currentDate <= endDate) { + const periodEnd = new Date(currentDate); + + // Set period end based on period type + if (period === 'day') { + periodEnd.setDate(periodEnd.getDate() + 1); + } else if (period === 'week') { + periodEnd.setDate(periodEnd.getDate() + 7); + } else { + // month + periodEnd.setMonth(periodEnd.getMonth() + 1); + } + + // Count users active in this period + const activeUsers = cohort.users.filter((user: any) => { + return user.events.some((event: any) => { + const eventDate = new Date(event.timestamp); + return eventDate >= currentDate && eventDate < periodEnd; + }); + }).length; + + const retention = cohort.size > 0 ? (activeUsers / cohort.size) * 100 : 0; + cohortData.retention.push(parseFloat(retention.toFixed(2))); + + // Track the period + const periodLabel = formatDate(currentDate, period as 'day' | 'week' | 'month'); + cohortData.periods.push(periodLabel); + allPeriods.add(periodLabel); + + // Move to next period + currentDate = periodEnd; + periodIndex++; + } + + cohorts.push(cohortData); + } + + // Ensure all cohorts have the same number of periods + const maxPeriods = Math.max(...cohorts.map(c => c.retention.length)); + for (const cohort of cohorts) { + while (cohort.retention.length < maxPeriods) { + cohort.retention.push(0); + // Add a placeholder period if needed + if (cohort.periods.length < maxPeriods) { + const lastPeriod = cohort.periods[cohort.periods.length - 1] || ''; + // Try to increment the period (simplified) + cohort.periods.push(`${lastPeriod}+`); + } + } + } + + return NextResponse.json(cohorts); + } catch (error) { + console.error('Error analyzing cohorts:', error); + return NextResponse.json( + { error: 'Failed to analyze cohorts' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/analytics/funnel/route.ts b/frontend/app/api/analytics/funnel/route.ts new file mode 100644 index 0000000..ee439ca --- /dev/null +++ b/frontend/app/api/analytics/funnel/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const steps = searchParams.get('steps')?.split(',') || []; + const startDate = new Date(searchParams.get('startDate') || ''); + const endDate = new Date(searchParams.get('endDate') || ''); + + if (steps.length === 0 || isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return NextResponse.json( + { error: 'Missing or invalid parameters' }, + { status: 400 } + ); + } + + const { db } = await connectToDatabase(); + const funnelData = []; + + // Get count for each step in the funnel + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + + // For the first step, just count the unique users + if (i === 0) { + const result = await db.collection('analytics_events').aggregate([ + { + $match: { + name: step, + timestamp: { + $gte: startDate.toISOString(), + $lte: endDate.toISOString(), + }, + }, + }, + { + $group: { + _id: '$userId', + count: { $sum: 1 }, + firstSeen: { $min: '$timestamp' }, + }, + }, + { + $count: 'total', + }, + ]).toArray(); + + funnelData.push({ + name: step, + count: result[0]?.total || 0, + percentage: 100, // First step is 100% + }); + } else { + // For subsequent steps, count users who completed this step after completing all previous steps + const previousSteps = steps.slice(0, i); + + const result = await db.collection('analytics_events').aggregate([ + { + $match: { + $or: [ + { name: step }, + ...previousSteps.map(prevStep => ({ name: prevStep })), + ], + timestamp: { + $gte: startDate.toISOString(), + $lte: endDate.toISOString(), + }, + }, + }, + { + $group: { + _id: '$userId', + steps: { $addToSet: '$name' }, + }, + }, + { + $match: { + // User must have all previous steps and the current step + $expr: { + $and: [ + { $in: [step, '$steps'] }, + ...previousSteps.map(prevStep => ({ + $in: [prevStep, '$steps'], + })), + ], + }, + }, + }, + { + $count: 'total', + }, + ]).toArray(); + + const percentage = funnelData[0]?.count > 0 + ? Math.round(((result[0]?.total || 0) / funnelData[0].count) * 100 * 100) / 100 + : 0; + + funnelData.push({ + name: step, + count: result[0]?.total || 0, + percentage, + }); + } + } + + return NextResponse.json(funnelData); + } catch (error) { + console.error('Error analyzing funnel:', error); + return NextResponse.json( + { error: 'Failed to analyze funnel' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/api/analytics/retention/route.ts b/frontend/app/api/analytics/retention/route.ts new file mode 100644 index 0000000..bb6c78a --- /dev/null +++ b/frontend/app/api/analytics/retention/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server'; +import { connectToDatabase } from '@/lib/mongodb'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const startDate = new Date(searchParams.get('startDate') || ''); + const endDate = new Date(searchParams.get('endDate') || ''); + const cohortSize = searchParams.get('cohortSize') || 'day'; + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return NextResponse.json( + { error: 'Invalid date range' }, + { status: 400 } + ); + } + + const { db } = await connectToDatabase(); + + // Get all users who performed any action in the date range + const usersPipeline = [ + { + $match: { + timestamp: { + $gte: startDate.toISOString(), + $lte: endDate.toISOString(), + }, + }, + }, + { + $group: { + _id: '$userId', + firstSeen: { $min: '$timestamp' }, + lastSeen: { $max: '$timestamp' }, + }, + }, + ]; + + const users = await db + .collection('analytics_events') + .aggregate(usersPipeline) + .toArray(); + + // Calculate retention metrics + const retentionData = []; + const days = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + + for (let day = 0; day <= days; day++) { + const currentDate = new Date(startDate); + currentDate.setDate(startDate.getDate() + day); + + const retainedUsers = users.filter(user => { + const userFirstSeen = new Date(user.firstSeen); + const userLastSeen = new Date(user.lastSeen); + + // Check if user was active on this day after their first seen date + return ( + userFirstSeen <= currentDate && + userLastSeen >= currentDate + ); + }).length; + + retentionData.push({ + day, + retained: retainedUsers, + percentage: users.length > 0 ? (retainedUsers / users.length) * 100 : 0, + }); + } + + return NextResponse.json(retentionData); + } catch (error) { + console.error('Error calculating retention:', error); + return NextResponse.json( + { error: 'Failed to calculate retention' }, + { status: 500 } + ); + } +} diff --git a/frontend/app/services/analytics/user-analytics.service.ts b/frontend/app/services/analytics/user-analytics.service.ts new file mode 100644 index 0000000..3d69b48 --- /dev/null +++ b/frontend/app/services/analytics/user-analytics.service.ts @@ -0,0 +1,170 @@ +import { analyticsService } from './analytics.service'; +import { AnalyticsEventNames } from '@/types/analytics'; + +export interface UserEvent { + userId: string; + event: string; + timestamp: Date; + properties?: Record; +} + +export interface RetentionMetrics { + day: number; + retained: number; + percentage: number; +} + +export interface FunnelStep { + name: string; + count: number; + percentage: number; +} + +export interface CohortData { + cohort: string; // e.g., '2023-01' for January 2023 cohort + size: number; + retention: number[]; // Retention rates for each period +} + +class UserAnalyticsService { + private static instance: UserAnalyticsService; + private baseUrl: string = process.env.NEXT_PUBLIC_API_URL || ''; + + private constructor() {} + + public static getInstance(): UserAnalyticsService { + if (!UserAnalyticsService.instance) { + UserAnalyticsService.instance = new UserAnalyticsService(); + } + return UserAnalyticsService.instance; + } + + /** + * Track a user event with additional user context + */ + public async trackUserEvent( + userId: string, + eventName: string, + properties: Record = {} + ): Promise { + await analyticsService.trackEvent(eventName, { + ...properties, + userId, + }); + } + + /** + * Calculate user retention metrics for a specific cohort + */ + public async calculateRetention( + startDate: Date, + endDate: Date, + cohortSize: 'day' | 'week' | 'month' = 'day' + ): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/analytics/retention?` + + new URLSearchParams({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + cohortSize, + }) + ); + + if (!response.ok) { + throw new Error('Failed to fetch retention data'); + } + + return response.json(); + } catch (error) { + console.error('Error calculating retention:', error); + throw error; + } + } + + /** + * Analyze user funnel through a series of steps + */ + public async analyzeFunnel( + steps: string[], + startDate: Date, + endDate: Date + ): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/analytics/funnel?` + + new URLSearchParams({ + steps: steps.join(','), + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + ); + + if (!response.ok) { + throw new Error('Failed to fetch funnel data'); + } + + return response.json(); + } catch (error) { + console.error('Error analyzing funnel:', error); + throw error; + } + } + + /** + * Perform cohort analysis + */ + public async analyzeCohorts( + startDate: Date, + endDate: Date, + period: 'day' | 'week' | 'month' = 'week', + cohortSize: 'day' | 'week' | 'month' = 'week' + ): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/analytics/cohorts?` + + new URLSearchParams({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + period, + cohortSize, + }) + ); + + if (!response.ok) { + throw new Error('Failed to fetch cohort data'); + } + + return response.json(); + } catch (error) { + console.error('Error analyzing cohorts:', error); + throw error; + } + } + + /** + * Get user journey for a specific user + */ + public async getUserJourney(userId: string): Promise { + try { + const response = await fetch( + `${this.baseUrl}/api/analytics/user-journey/${userId}` + ); + + if (!response.ok) { + throw new Error('Failed to fetch user journey'); + } + + const data = await response.json(); + return data.map((event: any) => ({ + ...event, + timestamp: new Date(event.timestamp), + })); + } catch (error) { + console.error('Error fetching user journey:', error); + throw error; + } + } +} + +export const userAnalyticsService = UserAnalyticsService.getInstance();