Modus-Werte yes/no statt left/right in DB, API und Statistik; Knöpfe "Ja"/"Nein". Bestehende Stimmen müssen per SQL migriert werden. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { api, errMsg } from "../api";
|
|
import type { StatsDetail } from "../types";
|
|
|
|
// Statistik-Seite: Zahlen + einfache CSS-Balken (keine Chart-Library).
|
|
// Balkenbreiten setzt React per CSSOM (element.style), nicht als
|
|
// style-Attribut im HTML -> von der strikten CSP nicht betroffen.
|
|
|
|
// NumCard ist eine Kennzahl-Kachel: große Zahl + Beschriftung darunter.
|
|
function NumCard({ value, label }: { value: number; label: string }) {
|
|
return (
|
|
<div>
|
|
<span className="num">{value}</span>
|
|
<span className="muted">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Bars zeichnet eine Liste {label, value} als horizontale Balken. Die Breite
|
|
// ist relativ zum größten Wert.
|
|
function Bars({ items, formatLabel }: {
|
|
items: { label: string; value: number }[];
|
|
formatLabel?: (label: string) => string;
|
|
}) {
|
|
if (items.length === 0) return <p className="muted">Noch keine Daten.</p>;
|
|
const max = Math.max(...items.map((it) => it.value), 1);
|
|
// Wertespalte für alle Zeilen gleich breit: an der längsten Zahl ausrichten
|
|
// (in ch), damit die Tracks bündig sind und nicht je nach Stellenzahl wandern.
|
|
const valWidth = Math.max(...items.map((it) => String(it.value).length));
|
|
return items.map((it) => (
|
|
<div className="bar-row" key={it.label}>
|
|
<span className="bar-label">{formatLabel ? formatLabel(it.label) : it.label}</span>
|
|
<div className="bar-track">
|
|
<div className="bar-fill" style={{ width: `${(it.value / max) * 100}%` }}></div>
|
|
</div>
|
|
<span className="bar-value" style={{ width: `${valWidth}ch` }}>{it.value}</span>
|
|
</div>
|
|
));
|
|
}
|
|
|
|
const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse
|
|
|
|
export function StatsPage() {
|
|
const stats = useQuery({
|
|
queryKey: ["stats"],
|
|
queryFn: () => api.get<StatsDetail>("/stats/detail"),
|
|
});
|
|
const s = stats.data;
|
|
// Backend liefert die Tage absteigend -> für die Achse drehen.
|
|
const daily = s ? s.daily.slice().reverse() : [];
|
|
|
|
return (
|
|
<section>
|
|
<h2>Statistik</h2>
|
|
|
|
<h3>Auf einen Blick</h3>
|
|
<div id="stat-overview" className="statgrid">
|
|
{stats.isError && <p className="msg">{errMsg(stats.error, "Statistik konnte nicht geladen werden.")}</p>}
|
|
{s && (
|
|
<>
|
|
<NumCard value={s.users} label={s.users === 1 ? "Profil" : "Profile"} />
|
|
<NumCard value={s.entries} label="Beiträge" />
|
|
<NumCard value={s.toplevel} label="davon Threads" />
|
|
<NumCard value={s.replies} label="davon Antworten" />
|
|
<NumCard value={s.votes.yes} label="Stimmen Ja" />
|
|
<NumCard value={s.votes.no} label="Stimmen Nein" />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<h3>Reichweite</h3>
|
|
<p className="muted">
|
|
Seitenaufrufe werden mit anonymisierten Netzen gezählt (IPv4 /16, IPv6 /32).
|
|
„Netze" ist eine grobe Annäherung an Besucher, keine Personenzählung.
|
|
</p>
|
|
<div id="stat-reach" className="statgrid">
|
|
{s && (
|
|
<>
|
|
<NumCard value={s.impressions_total} label="Aufrufe gesamt" />
|
|
<NumCard value={s.visitors_total} label="Netze gesamt" />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<h3>Netze nach Betreiber</h3>
|
|
<p className="muted">
|
|
Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN.
|
|
</p>
|
|
<div id="stat-asns" className="bars">
|
|
{s && <Bars items={s.top_asns.map((a) => ({ label: a.asn, value: a.hits }))} />}
|
|
</div>
|
|
|
|
<h3>Aufrufe pro Tag</h3>
|
|
<div id="stat-daily" className="bars">
|
|
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />}
|
|
</div>
|
|
|
|
<h3>Verschiedene Netze pro Tag</h3>
|
|
<div id="stat-networks" className="bars">
|
|
{s && <Bars items={daily.map((d) => ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|