package main
import (
"fmt"
"html"
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
_ "time/tzdata" // Alpine-Image hat keine Zonendaten
)
// Diagramme der Statistik-Seite. Sie werden einmal am Tag (kurz nach
// Mitternacht, deutsche Zeit) als fertiges SVG gerendert und im Speicher
// gehalten; ein Seitenaufruf liefert nur noch die Strings aus. Das SVG nutzt
// ausschließlich Präsentationsattribute (stroke, fill ...) und kein style-
// Attribut -> inline eingebettet von der strikten CSP nicht betroffen, und die
// Schrift erbt es von der Seite.
//
// Der laufende Tag fehlt bewusst: er wäre bis zum nächsten Rendern immer nur
// ein halber Tag und würde die Kurven am rechten Rand abknicken lassen.
var berlin = mustLoadLocation("Europe/Berlin")
func mustLoadLocation(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return loc
}
// StatsCharts ist die Antwort von /api/stats/charts. Jedes Diagramm gibt es
// schmal (Handy, schmale Fenster) und breit (Widescreen); welches sichtbar ist,
// entscheidet das CSS. So bleibt die Schrift in beiden Fällen etwa 1:1.
type StatsCharts struct {
Generated int64 `json:"generated"`
Charts map[string]ChartPair `json:"charts"`
}
type ChartPair struct {
Narrow string `json:"narrow"`
Wide string `json:"wide"`
}
// both rendert ein Diagramm in beiden Größen.
func both(render func(sz size) string) ChartPair {
return ChartPair{Narrow: render(narrow), Wide: render(wide)}
}
var (
chartMu sync.RWMutex
chartCache StatsCharts
chartStop = make(chan struct{})
chartDone = make(chan struct{})
)
// startChartWorker rendert sofort und danach jede Nacht neu. Schlägt das
// Rendern fehl, bleibt der alte Stand stehen und es gibt nach 15 Minuten einen
// neuen Versuch.
func startChartWorker() {
go func() {
defer close(chartDone)
for {
wait := 15 * time.Minute
if err := renderCharts(time.Now()); err != nil {
log.Printf("statistik-diagramme: %v", err)
} else {
wait = time.Until(nextChartRun(time.Now()))
}
select {
case <-chartStop:
return
case <-time.After(wait):
}
}
}()
}
// stopChartWorker wartet ein laufendes Rendern ab (vor db.Close rufen).
func stopChartWorker() {
close(chartStop)
<-chartDone
}
// nextChartRun ist eine Minute nach der nächsten Mitternacht in Berlin.
func nextChartRun(now time.Time) time.Time {
y, m, d := now.In(berlin).Date()
return time.Date(y, m, d+1, 0, 1, 0, 0, berlin)
}
func renderCharts(now time.Time) error {
impr, err := collectImpressionStats(now)
if err != nil {
return err
}
ent, err := collectEntryStats(now)
if err != nil {
return err
}
set := StatsCharts{
Generated: now.Unix(),
Charts: map[string]ChartPair{
"impressions": both(func(sz size) string {
return lineChart(sz, "Aufrufe pro Tag", impr.days, impr.hits, "Aufrufe")
}),
"networks": both(func(sz size) string {
return lineChart(sz, "Netze pro Tag", impr.days, impr.networks, "Netze")
}),
"entries": both(func(sz size) string {
return lineChart(sz, "Beiträge pro Tag", ent.days, ent.perDay, "Beiträge")
}),
"weekday": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Wochentag", weekdayNames[:], weekdayLong[:], ent.weekday)
}),
"hour": both(func(sz size) string {
return stackedBars(sz, "Beiträge pro Tageszeit", hourLabels(), hourRanges(), ent.hour)
}),
},
}
chartMu.Lock()
chartCache = set
chartMu.Unlock()
return nil
}
// handleStatsCharts liefert die fertigen SVGs. Ist noch nichts gerendert
// (Start läuft noch, Tests ohne Worker), wird einmalig direkt gerendert.
func handleStatsCharts(w http.ResponseWriter, r *http.Request) error {
chartMu.RLock()
set := chartCache
chartMu.RUnlock()
if set.Generated == 0 {
if err := renderCharts(time.Now()); err != nil {
return Internal(err)
}
chartMu.RLock()
set = chartCache
chartMu.RUnlock()
}
w.Header().Set("Cache-Control", "public, max-age=600")
writeJSON(w, http.StatusOK, set)
return nil
}
// --- Daten ---
type impressionStats struct {
days []time.Time // lückenlos, ohne heute
hits, networks []float64
}
// collectImpressionStats summiert Aufrufe und verschiedene Netze je Tag. Die
// Impression-Tage sind UTC-Daten (siehe recordImpression).
func collectImpressionStats(now time.Time) (impressionStats, error) {
var s impressionStats
today := civilDate(now.UTC())
rows, err := db.Query(
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
FROM impression
WHERE day < $1
GROUP BY day`, today.Format(time.DateOnly))
if err != nil {
return s, err
}
defer rows.Close()
type counts struct{ hits, networks int64 }
byDay := map[time.Time]counts{}
var first time.Time
for rows.Next() {
var day string
var c counts
if err := rows.Scan(&day, &c.hits, &c.networks); err != nil {
return s, err
}
d, err := time.Parse(time.DateOnly, day)
if err != nil {
continue
}
byDay[d] = c
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
for _, d := range s.days {
c := byDay[d]
s.hits = append(s.hits, float64(c.hits))
s.networks = append(s.networks, float64(c.networks))
}
return s, nil
}
// Reihenfolge der Stapel: Threads unten, Antworten darüber.
var postKinds = [2]string{"Threads", "Antworten"}
type entryStats struct {
days []time.Time // erster Beitragstag bis gestern (deutsche Zeit)
perDay []float64
weekday [][2]float64 // Mo..So, Ø Beiträge je solchem Tag
hour [][2]float64 // 0..23 Uhr, Ø Beiträge je Tag
}
// collectEntryStats zählt alle Beiträge einschließlich gelöschter (created_at
// bleibt beim Löschen erhalten). Tage, Wochentage und Stunden gelten in
// deutscher Zeit. Wochentag und Stunde sind Durchschnitte: Summe geteilt durch
// die Zahl der Montage, Dienstage ... bzw. aller Tage im Zeitraum.
func collectEntryStats(now time.Time) (entryStats, error) {
var s entryStats
today := civilDate(now.In(berlin))
midnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, berlin)
rows, err := db.Query(
`SELECT created_at, reply_to <> 0 FROM entry WHERE created_at > 0 AND created_at < $1`,
midnight.Unix())
if err != nil {
return s, err
}
defer rows.Close()
perDay := map[time.Time]float64{}
var weekdaySum [7][2]float64
var hourSum [24][2]float64
var first time.Time
for rows.Next() {
var created int64
var reply bool
if err := rows.Scan(&created, &reply); err != nil {
return s, err
}
t := time.Unix(created, 0).In(berlin)
d := civilDate(t)
kind := 0
if reply {
kind = 1
}
perDay[d]++
weekdaySum[isoWeekday(d)][kind]++
hourSum[t.Hour()][kind]++
if first.IsZero() || d.Before(first) {
first = d
}
}
if err := rows.Err(); err != nil {
return s, err
}
s.weekday = make([][2]float64, 7)
s.hour = make([][2]float64, 24)
if first.IsZero() {
return s, nil
}
s.days = dayRange(first, today)
var weekdayCount [7]float64
for _, d := range s.days {
s.perDay = append(s.perDay, perDay[d])
weekdayCount[isoWeekday(d)]++
}
for i := range s.weekday {
for k := range postKinds {
if weekdayCount[i] > 0 {
s.weekday[i][k] = weekdaySum[i][k] / weekdayCount[i]
}
}
}
n := float64(len(s.days))
for i := range s.hour {
for k := range postKinds {
s.hour[i][k] = hourSum[i][k] / n
}
}
return s, nil
}
// civilDate ist das Kalenderdatum von t (in dessen Zone) als UTC-Mitternacht.
// Gerechnet wird mit diesen Werten, damit Sommerzeitwechsel keine 23- oder
// 25-Stunden-Tage in die Tagesfolge bringen.
func civilDate(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
// dayRange liefert alle Tage von first bis ausschließlich end.
func dayRange(first, end time.Time) []time.Time {
var days []time.Time
for d := first; d.Before(end); d = d.AddDate(0, 0, 1) {
days = append(days, d)
}
return days
}
// isoWeekday: Montag = 0 ... Sonntag = 6.
func isoWeekday(d time.Time) int {
return (int(d.Weekday()) + 6) % 7
}
var weekdayNames = [7]string{"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}
var weekdayLong = [7]string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"}
// hourRanges: "9–10 Uhr" für die Infozeile.
func hourRanges() []string {
l := make([]string, 24)
for i := range l {
l[i] = fmt.Sprintf("%d–%d Uhr", i, i+1)
}
return l
}
func hourLabels() []string {
l := make([]string, 24)
for i := range l {
l[i] = strconv.Itoa(i)
}
return l
}
// --- SVG ---
// Maße im viewBox-Raum. narrow entspricht der schmalen Inhaltsspalte (600 px),
// wide der breiten Statistik-Seite (1200 px); bis dorthin wird 1:1 gezeichnet,
// darunter skaliert das SVG mit.
type size struct{ w, h int }
var (
narrow = size{600, 240}
wide = size{1200, 300}
)
const (
padLeft = 44
padRight = 12
padTop = 12
padBottom = 26
fontSize = 13
)
var (
colorGrid = "#ddd"
colorStack = [2]string{"#444", "#aaa"}
)
type svg struct{ strings.Builder }
func (b *svg) f(format string, a ...any) { fmt.Fprintf(b, format, a...) }
func svgOpen(b *svg, sz size, label string) {
b.f(``, sz.w/2, sz.h/2)
return b.String()
}
// niceScale wählt eine runde Schrittweite (1, 2, 5 × 10^k) für etwa vier
// Gitterlinien und die darauf aufgerundete Obergrenze.
func niceScale(max float64) (step, top float64) {
if max <= 0 {
max = 1
}
raw := max / 4
mag := math.Pow(10, math.Floor(math.Log10(raw)))
step = 10 * mag
for _, m := range []float64{1, 2, 5} {
if m*mag >= raw {
step = m * mag
break
}
}
return step, math.Ceil(max/step-1e-9) * step
}
// fmtNum schreibt eine Zahl mit höchstens zwei Nachkommastellen und
// deutschem Komma.
func fmtNum(v float64) string {
return strings.Replace(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), ".", ",", 1)
}
// yAxis zeichnet Gitterlinien mit Beschriftung und liefert die Abbildung
// Wert -> y-Koordinate.
func yAxis(b *svg, sz size, max float64, top int) func(float64) float64 {
step, maxV := niceScale(max)
bottom := float64(sz.h - padBottom)
y := func(v float64) float64 { return bottom - v/maxV*(bottom-float64(top)) }
for v := 0.0; v <= maxV+step/2; v += step {
yy := y(v)
color := colorGrid
if v == 0 {
color = "black"
}
b.f(`