Statistik: tägliche SVG-Diagramme statt CSS-Balken

Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme
für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/
Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und
breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den
Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie.

Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit.
"Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der
letzten 30 Tage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
irrlicht
2026-10-06 20:13:30 +02:00
co-authored by Claude Opus 5.5
parent 047ad392d9
commit 07a2b64eff
9 changed files with 881 additions and 137 deletions
+17 -58
View File
@@ -236,7 +236,8 @@ func handleStats(w http.ResponseWriter, r *http.Request) error {
return nil
}
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite. Anders als
// handleStatsDetail liefert die Kennzahlen für die Statistik-Seite (die
// Diagramme kommen getrennt aus handleStatsCharts, charts.go). Anders als
// /stats (Tagline, bei jedem Aufruf) darf das hier ruhig mehrere Aggregate
// rechnen. Öffentlich, kein Auth.
func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
@@ -257,67 +258,25 @@ func handleStatsDetail(w http.ResponseWriter, r *http.Request) error {
return Internal(err)
}
// Tägliche Impressions + grobe Unique-Netze, jüngste 30 Tage zuerst. Das
// Frontend dreht für die Achse auf aufsteigend.
daily := []map[string]any{}
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY day
ORDER BY day DESC
LIMIT 30`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var day string
var hits, visitors int64
if err := rows.Scan(&day, &hits, &visitors); err != nil {
return Internal(err)
}
daily = append(daily, map[string]any{"day": day, "impressions": hits, "visitors": visitors})
}
if err := rows.Err(); err != nil {
return Internal(err)
}
// Netze nach Betreiber (autonomes System). Leere asn ('') -> "(unbekannt)".
topASNs := []map[string]any{}
rows, err = db.Query(
`SELECT CASE WHEN asn = '' THEN '(unbekannt)' ELSE asn END AS a,
SUM(hits)::bigint AS h,
COUNT(DISTINCT anon_ip)
FROM impression
GROUP BY a
ORDER BY h DESC
LIMIT 10`)
if err != nil {
return Internal(err)
}
defer rows.Close()
for rows.Next() {
var asn string
var hits, networks int64
if err := rows.Scan(&asn, &hits, &networks); err != nil {
return Internal(err)
}
topASNs = append(topASNs, map[string]any{"asn": asn, "hits": hits, "networks": networks})
}
if err := rows.Err(); err != nil {
// Laufender Durchschnitt: Beiträge (wie in den Diagrammen einschließlich
// gelöschter) der letzten 30 vollen Tage deutscher Zeit, ohne heute.
now := time.Now().In(berlin)
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, berlin)
var recent int64
if err := db.QueryRow(`SELECT COUNT(*) FROM entry WHERE created_at >= $1 AND created_at < $2`,
midnight.AddDate(0, 0, -30).Unix(), midnight.Unix()).Scan(&recent); err != nil {
return Internal(err)
}
writeJSON(w, http.StatusOK, map[string]any{
"users": users,
"entries": entries,
"toplevel": toplevel,
"replies": entries - toplevel,
"votes": map[string]any{"yes": votesYes, "no": votesNo},
"impressions_total": imprTotal,
"visitors_total": visitorsTotal,
"daily": daily,
"top_asns": topASNs,
"users": users,
"entries": entries,
"toplevel": toplevel,
"replies": entries - toplevel,
"votes": map[string]any{"yes": votesYes, "no": votesNo},
"impressions_total": imprTotal,
"visitors_total": visitorsTotal,
"entries_per_day_30": float64(recent) / 30,
})
return nil
}