maj metric article
This commit is contained in:
@@ -14,7 +14,8 @@ import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Calendar,
|
||||
Map as MapIcon
|
||||
Map as MapIcon,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import AnalyticsMap from '@/components/AnalyticsMap';
|
||||
import CountryHistogram from '@/components/CountryHistogram';
|
||||
@@ -123,10 +124,63 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||
});
|
||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||
|
||||
// Article page views (impressions)
|
||||
const pageViewsByPath = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: {
|
||||
type: 'PAGE_VIEW',
|
||||
path: { startsWith: '/actualites/' },
|
||||
...currentWhere
|
||||
},
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
// Article dwell times (reading times)
|
||||
const dwellTimeByPath = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path'],
|
||||
where: {
|
||||
type: 'DWELL_TIME',
|
||||
path: { startsWith: '/actualites/' },
|
||||
...currentWhere
|
||||
},
|
||||
_avg: { value: true },
|
||||
_count: { id: true }
|
||||
});
|
||||
|
||||
// Article unique visitors grouping
|
||||
const uniqueVisitorsRaw = await prisma.analyticsEvent.groupBy({
|
||||
by: ['path', 'ip'],
|
||||
where: {
|
||||
type: 'PAGE_VIEW',
|
||||
path: { startsWith: '/actualites/' },
|
||||
...currentWhere
|
||||
}
|
||||
});
|
||||
|
||||
// Load blog posts to associate with analytics
|
||||
const blogPosts = await prisma.blogPost.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
author: true,
|
||||
publishedAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
const totalArticleViews = await prisma.analyticsEvent.count({
|
||||
where: {
|
||||
type: 'PAGE_VIEW',
|
||||
path: { startsWith: '/actualites/' },
|
||||
...currentWhere
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Fetch Previous Data (for Deltas)
|
||||
let prevTotalEvents = 0;
|
||||
let prevUniqueVisitors = 0;
|
||||
let prevMobileCount = 0;
|
||||
let prevArticleViews = 0;
|
||||
|
||||
if (prevWhere) {
|
||||
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
|
||||
@@ -142,6 +196,14 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||
_count: { id: true }
|
||||
});
|
||||
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
|
||||
|
||||
prevArticleViews = await prisma.analyticsEvent.count({
|
||||
where: {
|
||||
type: 'PAGE_VIEW',
|
||||
path: { startsWith: '/actualites/' },
|
||||
...prevWhere
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Recent Unique IPs
|
||||
@@ -176,9 +238,52 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||
uniqueVisitors,
|
||||
recentIPs,
|
||||
uniqueVisitorsPerCountry,
|
||||
totalArticleViews,
|
||||
articleStats: blogPosts.map(post => {
|
||||
const paths = [`/actualites/${post.id}`];
|
||||
if (post.slug) {
|
||||
paths.push(`/actualites/${post.slug}`);
|
||||
}
|
||||
|
||||
let views = 0;
|
||||
pageViewsByPath.forEach(item => {
|
||||
if (paths.includes(item.path)) {
|
||||
views += item._count.id;
|
||||
}
|
||||
});
|
||||
|
||||
const uniqueIps = new Set<string>();
|
||||
uniqueVisitorsRaw.forEach(item => {
|
||||
if (paths.includes(item.path) && item.ip) {
|
||||
uniqueIps.add(item.ip);
|
||||
}
|
||||
});
|
||||
const visitors = uniqueIps.size;
|
||||
|
||||
let totalDwellTime = 0;
|
||||
let dwellCount = 0;
|
||||
dwellTimeByPath.forEach(item => {
|
||||
if (paths.includes(item.path) && item._avg.value && item._count.id) {
|
||||
totalDwellTime += item._avg.value * item._count.id;
|
||||
dwellCount += item._count.id;
|
||||
}
|
||||
});
|
||||
const avgDwellTime = dwellCount > 0 ? Math.round(totalDwellTime / dwellCount) : 0;
|
||||
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
author: post.author,
|
||||
publishedAt: post.publishedAt,
|
||||
views,
|
||||
visitors,
|
||||
avgDwellTime
|
||||
};
|
||||
}).sort((a, b) => b.views - a.views),
|
||||
deltas: {
|
||||
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
||||
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
||||
articleViews: prevArticleViews > 0 ? ((totalArticleViews - prevArticleViews) / prevArticleViews) * 100 : null,
|
||||
mobile: prevTotalEvents > 0 && totalEvents > 0
|
||||
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
|
||||
: null
|
||||
@@ -189,7 +294,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
|
||||
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
|
||||
const searchParams = await searchParamsPromise;
|
||||
const range = searchParams.range || 'week';
|
||||
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas, uniqueVisitorsPerCountry, totalArticleViews, articleStats } = await getMetrics(range, searchParams.from, searchParams.to);
|
||||
|
||||
const rangeLabels: Record<string, string> = {
|
||||
day: 'Dernières 24h',
|
||||
@@ -250,7 +355,7 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="card border-t-4 border-indigo-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
|
||||
@@ -283,6 +388,22 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card border-t-4 border-rose-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-400">
|
||||
<BookOpen className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-white">{totalArticleViews.toLocaleString()}</div>
|
||||
<ComparisonBadge value={deltas.articleViews} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-slate-400 text-sm font-medium">Articles consultés</h3>
|
||||
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
||||
Vues totales des articles de blog
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card border-t-4 border-amber-500">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
@@ -426,6 +547,53 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Article Statistics Table */}
|
||||
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-rose-400" /> Statistiques par article
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-6 py-4">Article</th>
|
||||
<th className="px-6 py-4">Auteur</th>
|
||||
<th className="px-6 py-4 text-right">Vues</th>
|
||||
<th className="px-6 py-4 text-right">V. Uniques</th>
|
||||
<th className="px-6 py-4 text-right">Temps de lecture moyen</th>
|
||||
<th className="px-6 py-4 text-right">Date de publication</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{articleStats.map((post) => (
|
||||
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors text-xs">
|
||||
<td className="px-6 py-4 font-semibold text-slate-200">
|
||||
{post.title}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-400">
|
||||
{post.author}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-bold text-white">
|
||||
{post.views.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right font-bold text-indigo-400">
|
||||
{post.visitors.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-slate-300 font-mono">
|
||||
{post.avgDwellTime > 0 ? `${post.avgDwellTime}s` : '---'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-slate-400">
|
||||
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString('fr-FR') : 'Non publié'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent IP Connections Table */}
|
||||
<div className="card p-0 overflow-hidden lg:col-span-2">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user