diff --git a/src/app/api/analytics/summary/route.ts b/src/app/api/analytics/summary/route.ts
index 9233565..106d258 100644
--- a/src/app/api/analytics/summary/route.ts
+++ b/src/app/api/analytics/summary/route.ts
@@ -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
);
@@ -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);
@@ -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,
diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx
index ed4f89b..521e808 100644
--- a/src/components/dashboard/StatsGrid.tsx
+++ b/src/components/dashboard/StatsGrid.tsx
@@ -4,6 +4,7 @@ import React from 'react';
import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation';
+import { TrendData } from '@/types/analytics';
interface StatsGridProps {
stats: {
@@ -11,19 +12,42 @@ interface StatsGridProps {
activeQRCodes: number;
conversionRate: number;
};
+ trends?: {
+ totalScans?: TrendData;
+ comparisonPeriod?: 'week' | 'month';
+ };
}
-export const StatsGrid: React.FC = ({ stats }) => {
+export const StatsGrid: React.FC = ({ stats, trends }) => {
const { t } = useTranslation();
- // Only show growth if there are actual scans
- const showGrowth = stats.totalScans > 0;
+
+ // Build trend display text
+ const getTrendText = () => {
+ if (!trends?.totalScans) {
+ return 'No data yet';
+ }
+
+ const trend = trends.totalScans;
+ const sign = trend.isNegative ? '-' : '+';
+ const period = trends.comparisonPeriod || 'period';
+ const newLabel = trend.isNew ? ' (new)' : '';
+
+ return `${sign}${trend.percentage}%${newLabel} from last ${period}`;
+ };
+
+ const getTrendType = (): 'positive' | 'negative' | 'neutral' => {
+ if (!trends?.totalScans) return 'neutral';
+ if (trends.totalScans.trend === 'up') return 'positive';
+ if (trends.totalScans.trend === 'down') return 'negative';
+ return 'neutral';
+ };
const cards = [
{
title: t('dashboard.stats.total_scans'),
value: formatNumber(stats.totalScans),
- change: showGrowth ? '+12%' : 'No data yet',
- changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral',
+ change: getTrendText(),
+ changeType: getTrendType(),
icon: (
diff --git a/src/types/analytics.ts b/src/types/analytics.ts
new file mode 100644
index 0000000..1a7e8da
--- /dev/null
+++ b/src/types/analytics.ts
@@ -0,0 +1,50 @@
+export type TrendType = 'up' | 'down' | 'flat';
+
+export interface TrendData {
+ trend: TrendType;
+ percentage: number;
+ isNegative?: boolean;
+ isNew?: boolean; // When growing from 0 previous data
+}
+
+export interface AnalyticsSummary {
+ totalScans: number;
+ uniqueScans: number;
+ avgScansPerQR: number;
+ mobilePercentage: number;
+ topCountry: string;
+ topCountryPercentage: number;
+ scansTrend?: TrendData;
+ avgScansTrend?: TrendData;
+ comparisonPeriod: 'week' | 'month';
+ comparisonDays: number;
+}
+
+export interface CountryStats {
+ country: string;
+ count: number;
+ percentage: number;
+ trend: TrendType;
+ trendPercentage: number;
+ isNew?: boolean;
+}
+
+export interface QRPerformance {
+ id: string;
+ title: string;
+ type: string;
+ totalScans: number;
+ uniqueScans: number;
+ conversion: number;
+ trend: TrendType;
+ trendPercentage: number;
+ isNew?: boolean;
+}
+
+export interface AnalyticsResponse {
+ summary: AnalyticsSummary;
+ deviceStats: Record;
+ countryStats: CountryStats[];
+ dailyScans: Record;
+ qrPerformance: QRPerformance[];
+}