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, sz.h, html.EscapeString(label), fontSize) } func emptyChart(sz size, label string) string { var b svg svgOpen(&b, sz, label) b.f(`Noch keine Daten.`, 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(``, padLeft, yy, sz.w-padRight, yy, color) b.f(`%s`, padLeft-6, yy+4, fmtNum(v)) } return y } func maxOf(vals []float64) float64 { m := 0.0 for _, v := range vals { m = math.Max(m, v) } return m } // lineChart zeichnet eine Tagesreihe als Linie. Alle Tage seit Beginn werden // auf die volle Breite gestaucht; unsichtbare Streifen je Tag tragen den // Tooltip (), denselben Text in data-tip für die Infozeile unter dem // Diagramm (StatsPage.tsx) und eine senkrechte Markierung. func lineChart(sz size, label string, days []time.Time, vals []float64, unit string) string { if len(days) == 0 { return emptyChart(sz, label) } var b svg svgOpen(&b, sz, label) y := yAxis(&b, sz, maxOf(vals), padTop) plotW := float64(sz.w - padLeft - padRight) n := len(days) x := func(i int) float64 { if n == 1 { return padLeft + plotW/2 } return padLeft + float64(i)*plotW/float64(n-1) } bottom := sz.h - padBottom for _, i := range dateTicks(days) { xx := x(i) b.f(`<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black"/>`, xx, bottom, xx, bottom+4) tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16)) b.f(`<text x="%.1f" y="%d" text-anchor="middle">%s</text>`, tx, bottom+18, dateTickLabel(days[i])) } var pts strings.Builder for i, v := range vals { fmt.Fprintf(&pts, "%.1f,%.1f ", x(i), y(v)) } if n == 1 { b.f(`<circle cx="%.1f" cy="%.1f" r="3"/>`, x(0), y(vals[0])) } else { b.f(`<polyline points="%s" fill="none" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>`, strings.TrimSpace(pts.String())) } slot := plotW / float64(max(n-1, 1)) for i, d := range days { tip := fmt.Sprintf("%s, %s: %s %s", weekdayNames[isoWeekday(d)], d.Format("02.01.2006"), fmtNum(vals[i]), unit) // Die Markierungslinie ist versteckt; das CSS zeigt sie beim Hovern // über den Streifen (.chart g:hover .mark). b.f(`<g data-tip="%s"><title>%s`, tip, tip, x(i)-slot/2, padTop, slot, bottom-padTop) b.f(``, x(i), padTop, x(i), bottom) } b.f(``) return b.String() } var monthNames = [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"} // dateTicks wählt die beschrifteten Tage: Monatsanfänge, bei langen Reihen // nur jedes Quartal, Halbjahr oder Jahr; bei Reihen unter zwei Monaten die // Montage. Höchstens acht Beschriftungen. func dateTicks(days []time.Time) []int { var months []int for i, d := range days { if d.Day() == 1 { months = append(months, i) } } if len(months) >= 2 { for _, step := range []int{1, 3, 6, 12} { var picked []int for _, i := range months { if (int(days[i].Month())-1)%step == 0 { picked = append(picked, i) } } if len(picked) <= 8 || step == 12 { return picked } } } var mondays []int for i, d := range days { if d.Weekday() == time.Monday { mondays = append(mondays, i) } } for len(mondays) > 8 { var half []int for j := 0; j < len(mondays); j += 2 { half = append(half, mondays[j]) } mondays = half } return mondays } // dateTickLabel: "02.10." bei Wochen-Ticks, sonst Monatsname; der Januar // zeigt stattdessen die Jahreszahl. func dateTickLabel(d time.Time) string { if d.Day() != 1 { return d.Format("02.01.") } if d.Month() == time.January { return strconv.Itoa(d.Year()) } return monthNames[d.Month()-1] } // stackedBars zeichnet Ø Threads (unten) und Antworten (oben) je Kategorie // als gestapelte Säulen mit Legende. func stackedBars(sz size, label string, labels, tipLabels []string, vals [][2]float64) string { totals := make([]float64, len(vals)) for i, v := range vals { totals[i] = v[0] + v[1] } if maxOf(totals) == 0 { return emptyChart(sz, label) } var b svg svgOpen(&b, sz, label) top := padTop + 20 x0 := padLeft + 4 for k, name := range postKinds { b.f(``, x0, padTop-2, colorStack[k]) b.f(`%s`, x0+16, padTop+8, name) x0 += 16 + 9*len(name) + 16 } y := yAxis(&b, sz, maxOf(totals), top) bottom := float64(sz.h - padBottom) slot := float64(sz.w-padLeft-padRight) / float64(len(vals)) barW := slot * 0.7 for i, v := range vals { x := padLeft + float64(i)*slot + (slot-barW)/2 tip := html.EscapeString(fmt.Sprintf("%s: Ø %s Beiträge pro Tag (%s Threads, %s Antworten)", tipLabels[i], fmtNum(totals[i]), fmtNum(v[0]), fmtNum(v[1]))) b.f(`%s`, tip, tip) // Unsichtbare volle Säule, damit auch kurze Balken gut zu treffen sind. b.f(``, padLeft+float64(i)*slot, top, slot, bottom-float64(top)) base := 0.0 for k := range postKinds { if v[k] > 0 { b.f(``, x, y(base+v[k]), barW, y(base)-y(base+v[k]), colorStack[k]) } base += v[k] } b.f(``) b.f(`%s`, x+barW/2, bottom+18, html.EscapeString(labels[i])) } b.f(``) return b.String() }