426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
import { Suspense } from "react";
|
||
import Link from "next/link";
|
||
import { ArrowLeft } from "lucide-react";
|
||
import { verifySessionToken } from "@/lib/admin-auth";
|
||
import { createServiceClient } from "@/lib/supabase";
|
||
import { cookies } from "next/headers";
|
||
import { redirect } from "next/navigation";
|
||
import type { Metadata } from "next";
|
||
import { AnalyticsTabs } from "@/components/admin/AnalyticsTabs";
|
||
import { AdminNav } from "@/components/admin/AdminNav";
|
||
|
||
export const metadata: Metadata = { title: "Web-Analytics – MBO Tech IT" };
|
||
export const dynamic = "force-dynamic";
|
||
|
||
interface PageView {
|
||
id: string;
|
||
path: string;
|
||
timestamp: string;
|
||
device_type: string | null;
|
||
browser: string | null;
|
||
os: string | null;
|
||
session_id: string;
|
||
duration_ms: number | null;
|
||
}
|
||
|
||
interface FilterOption {
|
||
label: string;
|
||
tage: number;
|
||
beschreibung: string;
|
||
}
|
||
|
||
const FILTER_OPTIONS: FilterOption[] = [
|
||
{ label: "Heute", tage: 1, beschreibung: "letzter Tag" },
|
||
{ label: "Woche", tage: 7, beschreibung: "letzte 7 Tage" },
|
||
{ label: "Monat", tage: 30, beschreibung: "letzte 30 Tage" },
|
||
{ label: "3 Monate", tage: 90, beschreibung: "letzte 90 Tage" },
|
||
{ label: "Jahr", tage: 365, beschreibung: "letztes Jahr" },
|
||
];
|
||
|
||
function balkenBreite(wert: number, max: number): string {
|
||
if (max === 0) return "0%";
|
||
return `${Math.max(4, Math.round((wert / max) * 100))}%`;
|
||
}
|
||
|
||
function fmtDuration(ms: number | null): string {
|
||
if (!ms) return "–";
|
||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
||
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||
}
|
||
|
||
function fmtDateDE(isoDate: string): string {
|
||
const [year, month, day] = isoDate.split("-");
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
async function AnalyticsContent({
|
||
vonISO,
|
||
bisISO,
|
||
tage = null,
|
||
}: {
|
||
vonISO: string;
|
||
bisISO: string;
|
||
tage?: number | null;
|
||
}) {
|
||
const cookieStore = await cookies();
|
||
const token = cookieStore.get("admin_session")?.value;
|
||
|
||
if (!token) redirect("/admin/login");
|
||
|
||
const session = await verifySessionToken(token);
|
||
if (!session) redirect("/admin/login");
|
||
|
||
const db = createServiceClient();
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
|
||
const vonTimestamp = vonISO + "T00:00:00Z";
|
||
const bisTimestamp = bisISO + "T23:59:59.999Z";
|
||
|
||
const { data: allViews, error } = await db
|
||
.from("page_views")
|
||
.select("id, path, timestamp, device_type, browser, os, session_id, duration_ms")
|
||
.eq("is_bot", false)
|
||
.gte("timestamp", vonTimestamp)
|
||
.lte("timestamp", bisTimestamp)
|
||
.order("timestamp", { ascending: false });
|
||
|
||
const views: PageView[] = allViews ?? [];
|
||
|
||
const heuteViews = views.filter((v) => v.timestamp.startsWith(heute));
|
||
const uniqueSessionsHeute = new Set(heuteViews.map((v) => v.session_id)).size;
|
||
const mitDauer = views.filter((v) => v.duration_ms != null && v.duration_ms > 0);
|
||
const avgDuration =
|
||
mitDauer.length > 0
|
||
? Math.round(mitDauer.reduce((s, v) => s + v.duration_ms!, 0) / mitDauer.length)
|
||
: 0;
|
||
const mobileCount = views.filter((v) => v.device_type === "mobile").length;
|
||
const mobileRate = views.length > 0 ? Math.round((mobileCount / views.length) * 100) : 0;
|
||
|
||
const tagesMap: Record<string, number> = {};
|
||
for (const v of views) {
|
||
const tag = v.timestamp.slice(0, 10);
|
||
tagesMap[tag] = (tagesMap[tag] ?? 0) + 1;
|
||
}
|
||
const tagesStats = Object.entries(tagesMap)
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.map(([date, count]) => ({ date, count }));
|
||
const maxTagesWert = Math.max(0, ...tagesStats.map((s) => s.count));
|
||
|
||
const pathMap: Record<string, { count: number; durSum: number; durCount: number }> = {};
|
||
for (const v of views) {
|
||
if (!pathMap[v.path]) pathMap[v.path] = { count: 0, durSum: 0, durCount: 0 };
|
||
pathMap[v.path].count++;
|
||
if (v.duration_ms) {
|
||
pathMap[v.path].durSum += v.duration_ms;
|
||
pathMap[v.path].durCount++;
|
||
}
|
||
}
|
||
const topSeiten = Object.entries(pathMap)
|
||
.map(([path, d]) => ({
|
||
path,
|
||
count: d.count,
|
||
avgDuration: d.durCount > 0 ? Math.round(d.durSum / d.durCount) : null,
|
||
}))
|
||
.sort((a, b) => b.count - a.count)
|
||
.slice(0, 20);
|
||
const maxPathCount = Math.max(0, ...topSeiten.map((s) => s.count));
|
||
|
||
const browserMap: Record<string, number> = {};
|
||
for (const v of views) browserMap[v.browser ?? "Other"] = (browserMap[v.browser ?? "Other"] ?? 0) + 1;
|
||
const sortedBrowsers = Object.entries(browserMap).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||
|
||
const deviceMap: Record<string, number> = {};
|
||
for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1;
|
||
|
||
const osMap: Record<string, number> = {};
|
||
for (const v of views) osMap[v.os ?? "Other"] = (osMap[v.os ?? "Other"] ?? 0) + 1;
|
||
const sortedOs = Object.entries(osMap).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||
|
||
let filterDesc: string;
|
||
if (tage !== null) {
|
||
filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`;
|
||
} else {
|
||
filterDesc = `${fmtDateDE(vonISO)} – ${fmtDateDE(bisISO)}`;
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-red-500/10 border border-red-500/30 p-4 rounded-lg">
|
||
<p className="text-red-400 font-semibold">Fehler beim Laden der Daten:</p>
|
||
<p className="text-red-400/70 text-sm mt-2">{error.message}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const overviewContent = (
|
||
<div className="space-y-6">
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white">Web-Analytics</h1>
|
||
<p className="text-slate-500 text-sm mt-1">{filterDesc} · Bots gefiltert · IPs anonymisiert</p>
|
||
</div>
|
||
<Link
|
||
href="/admin"
|
||
className="inline-flex items-center gap-2 text-slate-500 hover:text-white transition-colors text-sm"
|
||
>
|
||
<ArrowLeft className="w-4 h-4" />
|
||
Zurück
|
||
</Link>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{FILTER_OPTIONS.map((option) => (
|
||
<Link
|
||
key={option.tage}
|
||
href={`/admin/analytics?tage=${option.tage}`}
|
||
className={`px-3 py-1 rounded text-sm font-medium transition-all ${
|
||
tage === option.tage && tage !== null
|
||
? "bg-orange-500 text-white"
|
||
: "bg-gray-800 text-slate-400 hover:bg-gray-700 hover:text-white"
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
|
||
<form method="GET" action="/admin/analytics" className="flex flex-col sm:flex-row gap-2 items-end bg-[#111925] p-3 rounded-lg">
|
||
<div className="flex-1 min-w-0">
|
||
<label htmlFor="von" className="block text-xs font-medium text-slate-400 mb-1">Von</label>
|
||
<input
|
||
id="von"
|
||
type="date"
|
||
name="von"
|
||
defaultValue={vonISO}
|
||
className="w-full px-3 py-2 rounded text-sm text-white bg-[#18212f] border border-gray-700 focus:outline-none focus:border-orange-500/60"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<label htmlFor="bis" className="block text-xs font-medium text-slate-400 mb-1">Bis</label>
|
||
<input
|
||
id="bis"
|
||
type="date"
|
||
name="bis"
|
||
defaultValue={bisISO}
|
||
className="w-full px-3 py-2 rounded text-sm text-white bg-[#18212f] border border-gray-700 focus:outline-none focus:border-orange-500/60"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-bold rounded transition-all whitespace-nowrap"
|
||
>
|
||
Anwenden
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "Seitenaufrufe heute", wert: heuteViews.length },
|
||
{ label: "Unique Sessions heute", wert: uniqueSessionsHeute },
|
||
{ label: "Ø Verweildauer", wert: fmtDuration(avgDuration) },
|
||
{ label: "Mobile-Anteil", wert: `${mobileRate}%` },
|
||
].map((k) => (
|
||
<div key={k.label} className="bg-[#18212f] border border-gray-800 p-4 rounded-lg">
|
||
<div className="text-xs text-slate-500 mb-1">{k.label}</div>
|
||
<div className="text-2xl font-bold text-white">{k.wert}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h2 className="text-base font-bold text-white mb-4">Seitenaufrufe ({filterDesc})</h2>
|
||
<div className="space-y-2">
|
||
{tagesStats.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
tagesStats.map(({ date, count }) => (
|
||
<div key={date} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-mono text-slate-500">{date}</div>
|
||
<div className="flex-1 h-8 bg-[#111925] rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-orange-500 transition-all duration-300"
|
||
style={{ width: balkenBreite(count, maxTagesWert) }}
|
||
/>
|
||
</div>
|
||
<div className="w-12 text-right text-sm font-semibold text-white">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg overflow-hidden">
|
||
<div className="p-6 border-b border-gray-800">
|
||
<h2 className="text-base font-bold text-white">Top-Seiten</h2>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="border-b border-gray-800">
|
||
<tr className="text-xs text-slate-500 uppercase tracking-wide">
|
||
<th className="px-6 py-3 text-left font-medium">Seite</th>
|
||
<th className="px-6 py-3 text-left font-medium">Aufrufe</th>
|
||
<th className="px-6 py-3 text-left font-medium">Ø Verweildauer</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-800/50">
|
||
{topSeiten.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={3} className="px-6 py-8 text-center text-slate-500">Keine Daten vorhanden</td>
|
||
</tr>
|
||
) : (
|
||
topSeiten.map(({ path, count, avgDuration }) => (
|
||
<tr key={path} className="hover:bg-[#111925] transition-colors">
|
||
<td className="px-6 py-3 font-mono text-slate-300 max-w-sm truncate">{path}</td>
|
||
<td className="px-6 py-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-32 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
|
||
<div className="h-full bg-orange-500" style={{ width: balkenBreite(count, maxPathCount) }} />
|
||
</div>
|
||
<span className="font-semibold text-white w-8">{count}</span>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-3 text-slate-400">{fmtDuration(avgDuration)}</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h2 className="text-base font-bold text-white mb-4">Browser</h2>
|
||
<div className="space-y-3">
|
||
{sortedBrowsers.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
sortedBrowsers.map(([browser, count]) => (
|
||
<div key={browser} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-medium text-slate-400">{browser}</div>
|
||
<div className="flex-1 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-orange-500"
|
||
style={{ width: balkenBreite(count, Math.max(...sortedBrowsers.map((s) => s[1]))) }}
|
||
/>
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h2 className="text-base font-bold text-white mb-4">Betriebssystem</h2>
|
||
<div className="space-y-3">
|
||
{sortedOs.length === 0 ? (
|
||
<p className="text-slate-500 text-sm">Keine Daten vorhanden</p>
|
||
) : (
|
||
sortedOs.map(([os, count]) => (
|
||
<div key={os} className="flex items-center gap-3">
|
||
<div className="w-24 text-sm font-medium text-slate-400">{os}</div>
|
||
<div className="flex-1 h-6 bg-[#111925] rounded flex items-center overflow-hidden">
|
||
<div
|
||
className="h-full bg-orange-500"
|
||
style={{ width: balkenBreite(count, Math.max(...sortedOs.map((s) => s[1]))) }}
|
||
/>
|
||
</div>
|
||
<div className="w-8 text-right text-sm font-semibold text-white">{count}</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#18212f] border border-gray-800 rounded-lg p-6">
|
||
<h2 className="text-base font-bold text-white mb-4">Geräte-Verteilung</h2>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{Object.entries(deviceMap).map(([device, count]) => (
|
||
<div key={device} className="text-center">
|
||
<div className="text-2xl font-bold text-white">{count}</div>
|
||
<div className="text-sm text-slate-400 capitalize">
|
||
{device === "desktop" ? "Desktop" : device === "tablet" ? "Tablet" : "Mobile"}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">
|
||
{views.length > 0 ? `${Math.round((count / views.length) * 100)}%` : "0%"}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-blue-500/10 border border-blue-500/20 p-4 rounded-lg">
|
||
<p className="text-sm text-blue-400">
|
||
<strong>Datenschutz:</strong> IP-Adressen werden anonymisiert (letztes Oktett = 0). Keine Cookies für Analytics.
|
||
Session-IDs sind Tab-gebunden und nicht persistent.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return <AnalyticsTabs overviewContent={overviewContent} />;
|
||
}
|
||
|
||
export default async function AnalyticsPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<Record<string, string | string[]>>;
|
||
}) {
|
||
const params = await searchParams;
|
||
const von = String(params.von || "");
|
||
const bis = String(params.bis || "");
|
||
const tageParam = String(params.tage || "");
|
||
|
||
const heute = new Date().toISOString().slice(0, 10);
|
||
|
||
function datumMinusTage(isoDate: string, tage: number): string {
|
||
const [year, month, day] = isoDate.split("-").map(Number);
|
||
const date = new Date(year, month - 1, day);
|
||
date.setDate(date.getDate() - tage);
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
let vonISO: string;
|
||
let bisISO: string;
|
||
let tage: number | null;
|
||
|
||
if (von && bis) {
|
||
vonISO = von;
|
||
bisISO = bis;
|
||
tage = null;
|
||
} else if (tageParam) {
|
||
tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30));
|
||
bisISO = heute;
|
||
vonISO = datumMinusTage(heute, tage);
|
||
} else {
|
||
tage = 30;
|
||
bisISO = heute;
|
||
vonISO = datumMinusTage(heute, 30);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#111925]">
|
||
<AdminNav />
|
||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||
<Suspense
|
||
fallback={
|
||
<div className="text-center py-12">
|
||
<p className="text-slate-500">Lädt Analytics-Daten…</p>
|
||
</div>
|
||
}
|
||
>
|
||
<AnalyticsContent vonISO={vonISO} bisISO={bisISO} tage={tage} />
|
||
</Suspense>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|