From f1d1f4291b2cd946b3fa21a69209b34eb74411bb Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Mon, 15 Dec 2025 20:35:50 +0100 Subject: [PATCH] Analytics --- src/app/(app)/analytics/page.tsx | 24 ++++++-- src/app/(app)/dashboard/page.tsx | 16 +++++- src/app/api/analytics/summary/route.ts | 76 ++++++++++++++++++++------ src/components/dashboard/StatsGrid.tsx | 40 +++++++++++--- src/types/analytics.ts | 50 +++++++++++++++++ 5 files changed, 175 insertions(+), 31 deletions(-) create mode 100644 src/types/analytics.ts diff --git a/src/app/(app)/analytics/page.tsx b/src/app/(app)/analytics/page.tsx index 64e716f..f4a9f9f 100644 --- a/src/app/(app)/analytics/page.tsx +++ b/src/app/(app)/analytics/page.tsx @@ -219,8 +219,14 @@ export default function AnalyticsPage() {

{analyticsData?.summary.totalScans.toLocaleString() || '0'}

-

0 ? 'text-green-600' : 'text-gray-500'}`}> - {analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period +

+ {analyticsData?.summary.scansTrend + ? `${analyticsData.summary.scansTrend.isNegative ? '-' : '+'}${analyticsData.summary.scansTrend.percentage}%${analyticsData.summary.scansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}` + : 'No data'}

@@ -231,8 +237,14 @@ export default function AnalyticsPage() {

{analyticsData?.summary.avgScansPerQR || '0'}

-

0 ? 'text-green-600' : 'text-gray-500'}`}> - {analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period +

+ {analyticsData?.summary.avgScansTrend + ? `${analyticsData.summary.avgScansTrend.isNegative ? '-' : '+'}${analyticsData.summary.avgScansTrend.percentage}%${analyticsData.summary.avgScansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}` + : 'No data'}

@@ -349,7 +361,7 @@ export default function AnalyticsPage() { country.trend === 'down' ? 'destructive' : 'default' }> - {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}% + {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%{country.isNew ? ' (new)' : ''} @@ -398,7 +410,7 @@ export default function AnalyticsPage() { qr.trend === 'down' ? 'destructive' : 'default' }> - {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}% + {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 176cda9..35aa311 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -41,6 +41,7 @@ export default function DashboardPage() { activeQRCodes: 0, conversionRate: 0, }); + const [analyticsData, setAnalyticsData] = useState(null); const mockQRCodes = [ { @@ -239,6 +240,13 @@ export default function DashboardPage() { const userData = await userResponse.json(); setUserPlan(userData.plan || 'FREE'); } + + // Fetch analytics data for trends (last 30 days = month comparison) + const analyticsResponse = await fetch('/api/analytics/summary?range=30'); + if (analyticsResponse.ok) { + const analytics = await analyticsResponse.json(); + setAnalyticsData(analytics); + } } catch (error) { console.error('Error fetching data:', error); setQrCodes([]); @@ -357,7 +365,13 @@ export default function DashboardPage() { {/* Stats Grid */} - + {/* Recent QR Codes */}
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: ( @@ -65,11 +89,11 @@ export const StatsGrid: React.FC = ({ stats }) => {

{card.title}

{card.value}

- {card.changeType === 'neutral' ? card.change : `${card.change} from last month`} + {card.change}

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[]; +}