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

@ -219,8 +219,14 @@ export default function AnalyticsPage() {
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.totalScans.toLocaleString() || '0'} {analyticsData?.summary.totalScans.toLocaleString() || '0'}
</p> </p>
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}> <p className={`text-sm mt-2 ${
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period analyticsData?.summary.scansTrend?.trend === 'up' ? 'text-green-600' :
analyticsData?.summary.scansTrend?.trend === 'down' ? 'text-red-600' :
'text-gray-500'
}`}>
{analyticsData?.summary.scansTrend
? `${analyticsData.summary.scansTrend.isNegative ? '-' : '+'}${analyticsData.summary.scansTrend.percentage}%${analyticsData.summary.scansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
: 'No data'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -231,8 +237,14 @@ export default function AnalyticsPage() {
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{analyticsData?.summary.avgScansPerQR || '0'} {analyticsData?.summary.avgScansPerQR || '0'}
</p> </p>
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}> <p className={`text-sm mt-2 ${
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period analyticsData?.summary.avgScansTrend?.trend === 'up' ? 'text-green-600' :
analyticsData?.summary.avgScansTrend?.trend === 'down' ? 'text-red-600' :
'text-gray-500'
}`}>
{analyticsData?.summary.avgScansTrend
? `${analyticsData.summary.avgScansTrend.isNegative ? '-' : '+'}${analyticsData.summary.avgScansTrend.percentage}%${analyticsData.summary.avgScansTrend.isNew ? ' (new)' : ''} from last ${analyticsData.summary.comparisonPeriod || 'period'}`
: 'No data'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -349,7 +361,7 @@ export default function AnalyticsPage() {
country.trend === 'down' ? 'destructive' : country.trend === 'down' ? 'destructive' :
'default' 'default'
}> }>
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}% {country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'} {country.trendPercentage}%{country.isNew ? ' (new)' : ''}
</Badge> </Badge>
</td> </td>
</tr> </tr>
@ -398,7 +410,7 @@ export default function AnalyticsPage() {
qr.trend === 'down' ? 'destructive' : qr.trend === 'down' ? 'destructive' :
'default' 'default'
}> }>
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}% {qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'} {qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
</Badge> </Badge>
</td> </td>
</tr> </tr>

View File

@ -41,6 +41,7 @@ export default function DashboardPage() {
activeQRCodes: 0, activeQRCodes: 0,
conversionRate: 0, conversionRate: 0,
}); });
const [analyticsData, setAnalyticsData] = useState<any>(null);
const mockQRCodes = [ const mockQRCodes = [
{ {
@ -239,6 +240,13 @@ export default function DashboardPage() {
const userData = await userResponse.json(); const userData = await userResponse.json();
setUserPlan(userData.plan || 'FREE'); 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) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
setQrCodes([]); setQrCodes([]);
@ -357,7 +365,13 @@ export default function DashboardPage() {
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<StatsGrid stats={stats} /> <StatsGrid
stats={stats}
trends={{
totalScans: analyticsData?.summary.scansTrend,
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
}}
/>
{/* Recent QR Codes */} {/* Recent QR Codes */}
<div> <div>

View File

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

View File

@ -4,6 +4,7 @@ import React from 'react';
import { Card, CardContent } from '@/components/ui/Card'; import { Card, CardContent } from '@/components/ui/Card';
import { formatNumber } from '@/lib/utils'; import { formatNumber } from '@/lib/utils';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { TrendData } from '@/types/analytics';
interface StatsGridProps { interface StatsGridProps {
stats: { stats: {
@ -11,19 +12,42 @@ interface StatsGridProps {
activeQRCodes: number; activeQRCodes: number;
conversionRate: number; conversionRate: number;
}; };
trends?: {
totalScans?: TrendData;
comparisonPeriod?: 'week' | 'month';
};
} }
export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => { export const StatsGrid: React.FC<StatsGridProps> = ({ stats, trends }) => {
const { t } = useTranslation(); 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 = [ const cards = [
{ {
title: t('dashboard.stats.total_scans'), title: t('dashboard.stats.total_scans'),
value: formatNumber(stats.totalScans), value: formatNumber(stats.totalScans),
change: showGrowth ? '+12%' : 'No data yet', change: getTrendText(),
changeType: showGrowth ? 'positive' : 'neutral' as 'positive' | 'negative' | 'neutral', changeType: getTrendType(),
icon: ( icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@ -69,7 +93,7 @@ export const StatsGrid: React.FC<StatsGridProps> = ({ stats }) => {
card.changeType === 'negative' ? 'text-red-600' : card.changeType === 'negative' ? 'text-red-600' :
'text-gray-500' 'text-gray-500'
}`}> }`}>
{card.changeType === 'neutral' ? card.change : `${card.change} from last month`} {card.change}
</p> </p>
</div> </div>
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600"> <div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center text-primary-600">

50
src/types/analytics.ts Normal file
View File

@ -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<string, number>;
countryStats: CountryStats[];
dailyScans: Record<string, number>;
qrPerformance: QRPerformance[];
}