MBO-Tech-IT-Webseite/modules/03-analytics/files/app/admin/analytics/page.tsx

495 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
export const metadata: Metadata = { title: "Web-Analytics" };
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" },
];
// Hilfsfunktionen (aus statistik/page.tsx übernommen)
function fmt(n: number, nachkomma = 0) {
return n.toLocaleString("de-DE", {
minimumFractionDigits: nachkomma,
maximumFractionDigits: nachkomma,
});
}
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`;
}
// Hilfsfunktion: Deutsches Datumsformat (DD.MM.YYYY)
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;
}) {
// ──── Auth Check ────────────────────────────────────────────────────────
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");
}
// ──── Daten abrufen (dynamischer Zeitraum, ohne Bots) ──────────────────────────
const db = createServiceClient();
const heute = new Date().toISOString().slice(0, 10);
// Konvertiere ISO-Dates zu vollständigen Timestamps für korrekte DB-Filterung
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 });
if (error) {
console.error("❌ Analytics Query Error:", error);
}
const views: PageView[] = allViews ?? [];
// ──── KPIs ──────────────────────────────────────────────────────────────
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;
// ──── Tagesstatistik (letzte 30 Tage) ────────────────────────────────────
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));
// ──── Top-Seiten ────────────────────────────────────────────────────────
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));
// ──── Browser-Verteilung ────────────────────────────────────────────────
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);
// ──── Gerät-Verteilung ──────────────────────────────────────────────────
const deviceMap: Record<string, number> = {};
for (const v of views) deviceMap[v.device_type ?? "desktop"] = (deviceMap[v.device_type ?? "desktop"] ?? 0) + 1;
// ──── OS-Verteilung ─────────────────────────────────────────────────────
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);
// ──── Filter-Button Label bestimmen ──────────────────────────────────
let filterDesc: string;
if (tage !== null) {
// Schnellauswahl
filterDesc = FILTER_OPTIONS.find((f) => f.tage === tage)?.beschreibung || `letzte ${tage} Tage`;
} else {
// Freier Zeitraum (deutsches Datumsformat)
filterDesc = `${fmtDateDE(vonISO)} ${fmtDateDE(bisISO)}`;
}
// Debug: Wenn Fehler oder keine Daten
if (error) {
return (
<div className="space-y-6">
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
</div>
<div className="bg-red-50 border border-red-300 p-4 rounded-lg">
<p className="text-red-800 font-semibold"> Fehler beim Laden der Daten:</p>
<p className="text-red-700 text-sm mt-2">{error.message}</p>
<p className="text-red-600 text-xs mt-3">
Prüfe in Supabase, ob die <code className="bg-red-100 px-1 rounded">page_views</code> Tabelle existiert und RLS korrekt konfiguriert ist.
</p>
</div>
</div>
);
}
const overviewContent = (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-[#1c1917] to-[#2d2b29] text-white py-8 px-6 rounded-lg">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Web-Analytics</h1>
<p className="text-white/70 text-sm mt-1">{filterDesc} · Bots gefiltert · IPs anonymisiert</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 text-white/60 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Zurück
</Link>
</div>
{/* Filter Buttons & Custom Date Range */}
<div className="space-y-3">
{/* Schnellauswahl-Buttons */}
<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-[#f7d334] text-[#1c1917] shadow-md"
: "bg-white/15 text-white hover:bg-white/25"
}`}
>
{option.label}
</Link>
))}
</div>
{/* Datumsauswahl-Formular */}
<form method="GET" action="/admin/analytics" className="flex flex-col sm:flex-row gap-2 items-end bg-white/10 p-3 rounded">
<div className="flex-1 min-w-0">
<label htmlFor="von" className="block text-xs font-medium text-white mb-1">
Von
</label>
<input
id="von"
type="date"
name="von"
defaultValue={vonISO}
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
/>
</div>
<div className="flex-1 min-w-0">
<label htmlFor="bis" className="block text-xs font-medium text-white mb-1">
Bis
</label>
<input
id="bis"
type="date"
name="bis"
defaultValue={bisISO}
className="w-full px-3 py-2 rounded text-sm text-slate-900 bg-white border-2 border-white font-medium"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-[#f7d334] text-[#1c1917] text-sm font-bold rounded hover:bg-[#d4a030] transition-all whitespace-nowrap"
>
Anwenden
</button>
</form>
</div>
</div>
{/* KPI-Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Seitenaufrufe heute</div>
<div className="text-2xl font-bold text-slate-900">{heuteViews.length}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Unique Sessions heute</div>
<div className="text-2xl font-bold text-slate-900">{uniqueSessionsHeute}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Ø Verweildauer</div>
<div className="text-2xl font-bold text-slate-900">{fmtDuration(avgDuration)}</div>
</div>
<div className="bg-white border border-slate-200 p-4 rounded-lg">
<div className="text-sm text-slate-500">Mobile-Anteil</div>
<div className="text-2xl font-bold text-slate-900">{mobileRate}%</div>
</div>
</div>
{/* Tagesdiagramm */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 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-600">{date}</div>
<div className="flex-1 h-8 bg-slate-100 rounded flex items-center justify-start overflow-hidden">
<div
className="h-full bg-[#f7d334] transition-all duration-300"
style={{ width: balkenBreite(count, maxTagesWert) }}
/>
</div>
<div className="w-12 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
{/* Top-Seiten */}
<div className="bg-white border border-slate-200 rounded-lg overflow-hidden">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-bold text-slate-900">Top-Seiten</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Seite</th>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Aufrufe</th>
<th className="px-6 py-3 text-left font-semibold text-slate-700">Ø Verweildauer</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{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-slate-50 transition-colors">
<td className="px-6 py-3 font-mono text-slate-900 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-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{ width: balkenBreite(count, maxPathCount) }}
/>
</div>
<span className="font-semibold text-slate-900 w-8">{count}</span>
</div>
</td>
<td className="px-6 py-3 text-slate-600">{fmtDuration(avgDuration)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Browser & OS Verteilung */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Browser */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 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-700">{browser}</div>
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{
width: balkenBreite(count, Math.max(...sortedBrowsers.map((s) => s[1]))),
}}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
{/* OS */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 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-700">{os}</div>
<div className="flex-1 h-6 bg-slate-100 rounded flex items-center overflow-hidden">
<div
className="h-full bg-[#f7d334]"
style={{
width: balkenBreite(count, Math.max(...sortedOs.map((s) => s[1]))),
}}
/>
</div>
<div className="w-8 text-right text-sm font-semibold text-slate-900">{count}</div>
</div>
))
)}
</div>
</div>
</div>
{/* Gerät-Verteilung */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h2 className="text-lg font-bold text-slate-900 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-slate-900">{count}</div>
<div className="text-sm text-slate-600 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>
{/* DSGVO-Hinweis */}
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg">
<p className="text-sm text-blue-800">
<strong>🔒 Datenschutz:</strong> IP-Adressen werden anonymisiert (letztes Oktett = 0). Es werden keine
Cookies für Analytics gesetzt. Session-IDs sind Tab-gebunden und werden nicht persistent gespeichert.
Weitere Details in der{" "}
<Link href="/datenschutz" className="underline hover:no-underline">
Datenschutzerklärung
</Link>
.
</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);
let vonISO: string;
let bisISO: string;
let tage: number | null;
// Hilfsfunktion: Datum um X Tage zurück (ohne Zeitzone-Probleme)
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")}`;
}
// Berechne den Zeitraum basierend auf Parametern
if (von && bis) {
// Freier Datumsbereich
vonISO = von;
bisISO = bis;
tage = null; // null bedeutet: freier Bereich, nicht Schnellauswahl
} else if (tageParam) {
// Schnellauswahl: von heute rückwärts
tage = Math.max(1, Math.min(365, parseInt(tageParam, 10) || 30));
bisISO = heute;
vonISO = datumMinusTage(heute, tage);
} else {
// Standard: 30 Tage von heute rückwärts
tage = 30;
bisISO = heute;
vonISO = datumMinusTage(heute, 30);
}
return (
<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>
);
}