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/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(``, xx, bottom, xx, bottom+4)
+ tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16))
+ b.f(`%s`, 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(``, x(0), y(vals[0]))
+ } else {
+ b.f(``,
+ 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(`%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, "