Analytics

This commit is contained in:
Timo Knuth
2025-12-15 20:35:50 +01:00
parent 09ebcf235d
commit f1d1f4291b
5 changed files with 175 additions and 31 deletions

View File

@@ -2,21 +2,41 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { db } from '@/lib/db';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { TrendData } from '@/types/analytics';
export const dynamic = 'force-dynamic';
// Helper function to calculate trend
function calculateTrend(current: number, previous: number): { trend: 'up' | 'down' | 'flat'; percentage: number } {
if (previous === 0) {
return current > 0 ? { trend: 'up', percentage: 100 } : { trend: 'flat', percentage: 0 };
// Helper function to calculate trend with proper edge case handling
function calculateTrend(current: number, previous: number): TrendData {
// Handle edge case: no data in either period
if (previous === 0 && current === 0) {
return { trend: 'flat', percentage: 0 };
}
const change = ((current - previous) / previous) * 100;
const percentage = Math.round(Math.abs(change));
// Handle new growth from zero - mark as "new" to distinguish from actual 100% growth
if (previous === 0 && current > 0) {
return { trend: 'up', percentage: 100, isNew: true };
}
if (change > 5) return { trend: 'up', percentage };
if (change < -5) return { trend: 'down', percentage };
return { trend: 'flat', percentage };
// Calculate actual percentage change
const change = ((current - previous) / previous) * 100;
const roundedChange = Math.round(change);
// Determine trend direction (use threshold of 5% to filter noise)
let trend: 'up' | 'down' | 'flat';
if (roundedChange > 5) {
trend = 'up';
} else if (roundedChange < -5) {
trend = 'down';
} else {
trend = 'flat';
}
return {
trend,
percentage: Math.abs(roundedChange),
isNegative: roundedChange < 0
};
}
export async function GET(request: NextRequest) {
@@ -52,14 +72,18 @@ export async function GET(request: NextRequest) {
const range = searchParams.get('range') || '30';
const daysInRange = parseInt(range, 10);
// Standardize to week (7 days) or month (30 days) for clear comparison labels
const comparisonDays = daysInRange <= 7 ? 7 : 30;
const comparisonPeriod: 'week' | 'month' = comparisonDays === 7 ? 'week' : 'month';
// Calculate current and previous period dates
const now = new Date();
const currentPeriodStart = new Date();
currentPeriodStart.setDate(now.getDate() - daysInRange);
currentPeriodStart.setDate(now.getDate() - comparisonDays);
const previousPeriodEnd = new Date(currentPeriodStart);
const previousPeriodStart = new Date(previousPeriodEnd);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - daysInRange);
previousPeriodStart.setDate(previousPeriodEnd.getDate() - comparisonDays);
// Get user's QR codes with scans filtered by period
const qrCodes = await db.qRCode.findMany({
@@ -101,6 +125,22 @@ export async function GET(request: NextRequest) {
const previousUniqueScans = qrCodesWithPreviousScans.reduce((sum, qr) =>
sum + qr.scans.filter(s => s.isUnique).length, 0
);
// Calculate average scans per QR code (only count QR codes with scans)
const qrCodesWithScans = qrCodes.filter(qr => qr.scans.length > 0).length;
const avgScansPerQR = qrCodesWithScans > 0
? Math.round(totalScans / qrCodesWithScans)
: 0;
// Calculate previous period average scans per QR
const previousQrCodesWithScans = qrCodesWithPreviousScans.filter(qr => qr.scans.length > 0).length;
const previousAvgScansPerQR = previousQrCodesWithScans > 0
? Math.round(previousTotalScans / previousQrCodesWithScans)
: 0;
// Calculate trends
const scansTrend = calculateTrend(totalScans, previousTotalScans);
const avgScansTrend = calculateTrend(avgScansPerQR, previousAvgScansPerQR);
// Device stats
const deviceStats = qrCodes.flatMap(qr => qr.scans)
@@ -118,7 +158,7 @@ export async function GET(request: NextRequest) {
// Country stats (current period)
const countryStats = qrCodes.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
@@ -126,7 +166,7 @@ export async function GET(request: NextRequest) {
// Country stats (previous period)
const previousCountryStats = qrCodesWithPreviousScans.flatMap(qr => qr.scans)
.reduce((acc, scan) => {
const country = scan.country || 'Unknown';
const country = scan.country ?? 'Unknown Location';
acc[country] = (acc[country] || 0) + 1;
return acc;
}, {} as Record<string, number>);
@@ -166,6 +206,7 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
})
.sort((a, b) => b.totalScans - a.totalScans);
@@ -174,14 +215,16 @@ export async function GET(request: NextRequest) {
summary: {
totalScans,
uniqueScans,
avgScansPerQR: qrCodes.length > 0
? Math.round(totalScans / qrCodes.length)
: 0,
avgScansPerQR,
mobilePercentage,
topCountry: topCountry ? topCountry[0] : 'N/A',
topCountryPercentage: topCountry && totalScans > 0
? Math.round((topCountry[1] / totalScans) * 100)
: 0,
scansTrend,
avgScansTrend,
comparisonPeriod,
comparisonDays,
},
deviceStats,
countryStats: Object.entries(countryStats)
@@ -199,6 +242,7 @@ export async function GET(request: NextRequest) {
: 0,
trend: trendData.trend,
trendPercentage: trendData.percentage,
...(trendData.isNew && { isNew: true }),
};
}),
dailyScans,