From 07a2b64eff4f90ddd3454b7f6f16cbfbdaa88715 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Tue, 6 Oct 2026 20:13:30 +0200 Subject: [PATCH] =?UTF-8?q?Statistik:=20t=C3=A4gliche=20SVG-Diagramme=20st?= =?UTF-8?q?att=20CSS-Balken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q --- charts.go | 566 +++++++++++++++++++++++++++++++ charts_test.go | 199 +++++++++++ frontend/src/Layout.tsx | 6 +- frontend/src/app.css | 61 ++-- frontend/src/pages/StatsPage.tsx | 90 ++--- frontend/src/types.ts | 15 +- main.go | 3 + notes/api.md | 3 +- stats.go | 75 +--- 9 files changed, 881 insertions(+), 137 deletions(-) create mode 100644 charts.go create mode 100644 charts_test.go diff --git a/charts.go b/charts.go new file mode 100644 index 0000000..9dbd306 --- /dev/null +++ b/charts.go @@ -0,0 +1,566 @@ +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() +} diff --git a/charts_test.go b/charts_test.go new file mode 100644 index 0000000..9e9cd78 --- /dev/null +++ b/charts_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "encoding/json" + "encoding/xml" + "io" + "math" + "net/http" + "strings" + "testing" + "time" +) + +func TestNiceScale(t *testing.T) { + cases := []struct{ max, step, top float64 }{ + {0, 0.5, 1}, + {1, 0.5, 1}, + {7, 2, 8}, + {123, 50, 150}, + {0.37, 0.1, 0.4}, + {1000, 500, 1000}, + } + for _, c := range cases { + step, top := niceScale(c.max) + if math.Abs(step-c.step) > 1e-9 || math.Abs(top-c.top) > 1e-9 { + t.Errorf("niceScale(%v) = %v, %v; erwartet %v, %v", c.max, step, top, c.step, c.top) + } + } +} + +func TestDateTicks(t *testing.T) { + day := func(s string) time.Time { d, _ := time.Parse(time.DateOnly, s); return d } + labels := func(from, to string) []string { + days := dayRange(day(from), day(to)) + var l []string + for _, i := range dateTicks(days) { + l = append(l, dateTickLabel(days[i])) + } + return strings.Fields(strings.Join(l, " ")) + } + if got := strings.Join(labels("2026-06-18", "2026-10-06"), " "); got != "Jul Aug Sep Okt" { + t.Errorf("Monate: %q", got) + } + if got := strings.Join(labels("2025-12-13", "2027-12-01"), " "); got != "2026 Apr Jul Okt 2027 Apr Jul Okt" { + t.Errorf("Quartale: %q", got) + } + if got := strings.Join(labels("2026-09-20", "2026-10-06"), " "); got != "21.09. 28.09. 05.10." { + t.Errorf("Wochen: %q", got) + } +} + +// Ende-zu-Ende: Beiträge und Aufrufe direkt in die DB, Aggregate prüfen, +// dann über die API ausliefern und jedes SVG als XML parsen. +func TestStatsCharts(t *testing.T) { + srv := newTestServer(t) + at := func(s string) int64 { + tm, err := time.ParseInLocation("2006-01-02 15:04", s, berlin) + if err != nil { + t.Fatal(err) + } + return tm.Unix() + } + // Mo 2026-09-28 bis Mi 2026-09-30; "heute" ist Do 2026-10-01. + posts := []struct { + created int64 + reply int64 + deleted int64 + }{ + {at("2026-09-28 09:30"), 0, 0}, + {at("2026-09-28 09:45"), 1, 0}, + {at("2026-09-28 23:30"), 1, 1}, // gelöscht, zählt trotzdem; UTC wäre es 21 Uhr + {at("2026-09-30 00:10"), 0, 0}, + {at("2026-10-01 08:00"), 0, 0}, // heute -> fehlt + } + for i, p := range posts { + if _, err := db.Exec(`INSERT INTO entry (pid, uid, created_at, reply_to, deleted) VALUES ($1, 1, $2, $3, $4)`, + 100+i, p.created, p.reply, p.deleted); err != nil { + t.Fatal(err) + } + } + for _, imp := range []struct { + day, ip string + hits int + }{ + {"2026-09-27", "10.1.0.0", 3}, + {"2026-09-27", "10.2.0.0", 2}, + {"2026-09-29", "10.1.0.0", 1}, + {"2026-10-01", "10.1.0.0", 9}, // heute -> fehlt + } { + db.Exec(`INSERT INTO impression (day, anon_ip, path, hits) VALUES ($1, $2, '/', $3)`, imp.day, imp.ip, imp.hits) + } + now := time.Date(2026, 10, 1, 12, 0, 0, 0, berlin) + + ent, err := collectEntryStats(now) + if err != nil { + t.Fatal(err) + } + if len(ent.days) != 3 || ent.perDay[0] != 3 || ent.perDay[1] != 0 || ent.perDay[2] != 1 { + t.Errorf("perDay %v über %d Tage", ent.perDay, len(ent.days)) + } + // Je ein Mo/Di/Mi im Zeitraum -> Ø = Summe. + if ent.weekday[0] != [2]float64{1, 2} || ent.weekday[2] != [2]float64{1, 0} || ent.weekday[3] != [2]float64{} { + t.Errorf("weekday %v", ent.weekday) + } + // Stunden: Summe / 3 Tage. + if ent.hour[9] != [2]float64{1.0 / 3, 1.0 / 3} || ent.hour[23] != [2]float64{0, 1.0 / 3} || ent.hour[0] != [2]float64{1.0 / 3, 0} { + t.Errorf("hour 0/9/23: %v %v %v", ent.hour[0], ent.hour[9], ent.hour[23]) + } + + impr, err := collectImpressionStats(now) + if err != nil { + t.Fatal(err) + } + wantHits, wantNets := []float64{5, 0, 1, 0}, []float64{2, 0, 1, 0} + if len(impr.days) != 4 { + t.Fatalf("impression-Tage: %v", impr.days) + } + for i := range wantHits { + if impr.hits[i] != wantHits[i] || impr.networks[i] != wantNets[i] { + t.Errorf("Tag %d: %v/%v", i, impr.hits[i], impr.networks[i]) + } + } + + if err := renderCharts(now); err != nil { + t.Fatal(err) + } + resp, err := http.Get(srv.URL + "/api/stats/charts") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var set StatsCharts + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + t.Fatal(err) + } + for _, name := range []string{"impressions", "networks", "entries", "weekday", "hour"} { + pair := set.Charts[name] + if !strings.Contains(pair.Narrow, `viewBox="0 0 600 `) || !strings.Contains(pair.Wide, `viewBox="0 0 1200 `) { + t.Errorf("%s: falsche Größen", name) + } + for _, s := range []string{pair.Narrow, pair.Wide} { + if !strings.HasPrefix(s, " 1e-9 { + t.Errorf("entries_per_day_30 = %v, erwartet 0,1", body.Avg) + } +} diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx index 8bc59f0..7c805a5 100644 --- a/frontend/src/Layout.tsx +++ b/frontend/src/Layout.tsx @@ -13,7 +13,8 @@ function plural(n: number, one: string, many: string) { // Layout ist der gemeinsame Seitenrahmen: die Startseite hat Tagline und // Headerbar, alle anderen Seiten den "zurück zum Feed"-Knopf. export function Layout() { - const isHome = useLocation().pathname === "/"; + const path = useLocation().pathname; + const isHome = path === "/"; const days = Math.floor((Date.now() - dataLossStart) / 86400000); return ( @@ -38,7 +39,8 @@ export function Layout() { )} -
+ {/* Die Statistik-Seite darf auf breiten Bildschirmen breiter werden. */} +
diff --git a/frontend/src/app.css b/frontend/src/app.css index ba725bd..7736363 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -117,6 +117,10 @@ main { margin: auto; } +main.wide { + max-width: 1200px; +} + main > section, main > div, main > h2, @@ -417,41 +421,34 @@ form input[type="file"] { line-height: 1.1; } -/* Horizontale Balken (Aufrufe/Tag, Betreiber). Breite setzt React per CSSOM - (kein Inline-style im HTML -> CSP-konform). */ -.bars { - padding: 0.3rem 0.5rem 0.6rem; +/* Server-SVGs (charts.go): schmal 600, breit 1200 im viewBox-Raum. Ab 900 px + Fensterbreite die breite Fassung, damit die Schrift nicht zu groß wird. */ +.chart { + padding: 0.3rem 0 0.6rem; } -.bar-row { - display: flex; - align-items: center; - gap: 0.5rem; - margin: 0.15rem 0; +.chart svg { + display: block; + width: 100%; + height: auto; } -.bar-label { - flex: 0 0 7rem; - text-align: right; - font-size: 0.95rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.chart-wide { + display: none; } -.bar-track { - flex: 1; - background: #eee; +/* Senkrechte Markierung des Tages unter dem Zeiger (Liniendiagramme). */ +.chart g:hover .mark { + visibility: visible; } -.bar-fill { - height: 1.1rem; - background: gray; +/* Infozeile unter dem Diagramm: Höhe immer reserviert. */ +.chart-tip { + margin: 0.2rem 0 0; + padding-left: 0.5rem; + min-height: 1.3em; } -.bar-value { - flex: 0 0 auto; - text-align: right; - font-size: 0.95rem; -} - -/* ASN-Namen sind länger als ein Datum -> mehr Platz, Umbruch statt Ellipse. */ -#stat-asns .bar-label { - flex-basis: 13rem; - white-space: normal; +@media (min-width: 900px) { + .chart-narrow { + display: none; + } + .chart-wide { + display: block; + } } diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 91c95b9..e89c08d 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -1,53 +1,55 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { api, errMsg } from "../api"; -import type { StatsDetail } from "../types"; +import type { ChartPair, StatsCharts, 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. +// Statistik-Seite: Zahlen und Diagramme, die der Server einmal täglich als SVG +// rendert (charts.go). Die SVGs kommen ohne style-Attribute aus -> 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 (
- {value} + {value.toLocaleString("de-DE", { maximumFractionDigits: 1, useGrouping: false })} {label}
); } -// 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

Noch keine Daten.

; - 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) => ( -
- {formatLabel ? formatLabel(it.label) : it.label} -
-
-
- {it.value} +// Chart bettet ein Server-SVG inline ein, damit es die Schrift der Seite erbt. +// Der Inhalt stammt vollständig aus charts.go (nur Zahlen und feste Texte). +// Beide Größen stehen im DOM, das CSS zeigt je nach Fensterbreite eine. +// Die Infozeile darunter ist immer reserviert (kein Springen) und zeigt den +// data-tip des Datenpunkts unter dem Zeiger; Tippen genügt auf Touch-Geräten. +function Chart({ svg }: { svg?: ChartPair }) { + const [tip, setTip] = useState(""); + if (!svg) return null; + const pick = (e: React.MouseEvent) => { + const el = (e.target as Element).closest("[data-tip]"); + if (el) setTip(el.getAttribute("data-tip") ?? ""); + }; + return ( +
setTip("")}> +
+
+

{tip || "\u00a0"}

- )); + ); } -const dayAxis = (day: string) => day.slice(5); // "MM-DD" reicht auf der Achse - export function StatsPage() { const stats = useQuery({ queryKey: ["stats"], queryFn: () => api.get("/stats/detail"), }); + const charts = useQuery({ + queryKey: ["stats-charts"], + queryFn: () => api.get("/stats/charts"), + staleTime: 10 * 60 * 1000, + }); const s = stats.data; - // Backend liefert die Tage absteigend -> für die Achse drehen. - const daily = s ? s.daily.slice().reverse() : []; + const c = charts.data?.charts; return (
@@ -62,6 +64,7 @@ export function StatsPage() { + @@ -82,23 +85,26 @@ export function StatsPage() { )}
-

Netze nach Betreiber

-

- Aufrufe je autonomem System (Netzbetreiber), aufgelöst über GeoLite2-ASN. -

-
- {s && ({ label: a.asn, value: a.hits }))} />} -
-

Aufrufe pro Tag

-
- {s && ({ label: d.day, value: d.impressions }))} formatLabel={dayAxis} />} -
+

Verschiedene Netze pro Tag

-
- {s && ({ label: d.day, value: d.visitors }))} formatLabel={dayAxis} />} -
+ + +

Beiträge pro Tag

+

+ Alle Beiträge einschließlich gelöschter, in deutscher Zeit. Wochentag und + Tageszeit zeigen den Durchschnitt pro Tag seit dem ersten Beitrag. + Die Diagramme werden einmal täglich erstellt und enden mit gestern. +

+ {charts.isError &&

{errMsg(charts.error, "Diagramme konnten nicht geladen werden.")}

} + + +

Beiträge pro Wochentag

+ + +

Beiträge pro Tageszeit

+ ); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da93c55..0eba7f5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -79,8 +79,19 @@ export interface StatsDetail { votes: { yes: number; no: number }; impressions_total: number; visitors_total: number; - daily: { day: string; impressions: number; visitors: number }[]; - top_asns: { asn: string; hits: number; networks: number }[]; + entries_per_day_30: number; +} + +// Fertig gerenderte SVG-Diagramme der Statistik-Seite (einmal täglich, +// charts.go), je schmal (600) und breit (1200). +export interface ChartPair { + narrow: string; + wide: string; +} + +export interface StatsCharts { + generated: number; + charts: Record<"impressions" | "networks" | "entries" | "weekday" | "hour", ChartPair>; } // Fehlerformat: {"error": {...}}, bei manchen Fehlern mit Zusatzfeldern diff --git a/main.go b/main.go index d714ee9..9cbf29c 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,8 @@ func main() { defer stopImpressionWorker() startPreviewWorker() defer stopPreviewWorker() + startChartWorker() + defer stopChartWorker() srv := &http.Server{ Addr: envOr("KVER_ADDR", ":8080"), @@ -146,6 +148,7 @@ func routes() http.Handler { r.Get("/auth/sessioninfo", api(handleSessionInfo)) r.Get("/stats", api(handleStats)) r.Get("/stats/detail", api(handleStatsDetail)) + r.Get("/stats/charts", api(handleStatsCharts)) // --- Entries (öffentlich) --- r.Get("/entry/feed/{page}", api(handleFeed)) diff --git a/notes/api.md b/notes/api.md index e53831b..fdd8645 100644 --- a/notes/api.md +++ b/notes/api.md @@ -138,7 +138,8 @@ TOR-Anfragen auf `create` werden mit `403 tor.blocked` abgewiesen. | Methode | Pfad | Erfolg | |---------|------|--------| | GET | `/api/stats` | `200 {"users","entries"}` | -| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","daily":[…],"top_asns":[…]}` | +| GET | `/api/stats/detail` | `200 {"users","entries","toplevel","replies","votes":{"yes","no"},"impressions_total","visitors_total","entries_per_day_30"}` (Ø Beiträge pro Tag der letzten 30 vollen Tage) | +| GET | `/api/stats/charts` | `200 {"generated","charts":{"impressions","networks","entries","weekday","hour"}}`, je `{"narrow","wide"}` als fertiges SVG (600 bzw. 1200 breit), einmal täglich gerendert | --- diff --git a/stats.go b/stats.go index 1251301..8a68f5e 100644 --- a/stats.go +++ b/stats.go @@ -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 }