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

426 lines
16 KiB
TypeScript
Raw 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";
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>
);
}