Analytics
This commit is contained in:
@@ -219,8 +219,14 @@ export default function AnalyticsPage() {
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{analyticsData?.summary.totalScans.toLocaleString() || '0'}
|
||||
</p>
|
||||
<p className={`text-sm mt-2 ${analyticsData?.summary.totalScans > 0 ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{analyticsData?.summary.totalScans > 0 ? '+12.5%' : 'No data'} from last period
|
||||
<p className={`text-sm mt-2 ${
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -231,8 +237,14 @@ export default function AnalyticsPage() {
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{analyticsData?.summary.avgScansPerQR || '0'}
|
||||
</p>
|
||||
<p className={`text-sm mt-2 ${analyticsData?.summary.avgScansPerQR > 0 ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{analyticsData?.summary.avgScansPerQR > 0 ? '+8.3%' : 'No data'} from last period
|
||||
<p className={`text-sm mt-2 ${
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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)' : ''}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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)' : ''}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function DashboardPage() {
|
||||
activeQRCodes: 0,
|
||||
conversionRate: 0,
|
||||
});
|
||||
const [analyticsData, setAnalyticsData] = useState<any>(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() {
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid stats={stats} />
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
trends={{
|
||||
totalScans: analyticsData?.summary.scansTrend,
|
||||
comparisonPeriod: analyticsData?.summary.comparisonPeriod || 'month'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Recent QR Codes */}
|
||||
<div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user