Files
spsays-bff/internal/handler/stats.go
T
2026-05-31 14:54:15 +05:30

131 lines
5.5 KiB
Go

package handler
import (
"html/template"
"net/http"
"time"
"github.com/prabhupadasays/spsays-bff/internal/repository"
)
// StatsHandler renders a single-page HTML snapshot of the system —
// aggregate counts plus the most-recent feedback rows. Mounted at /stats
// (outside /api/v1, since it returns HTML not JSON) and intentionally
// public per current product call. If that ever needs to change, wrap
// the route in middleware in server.go.
type StatsHandler struct {
stats *repository.StatsRepo
feedback *repository.FeedbackRepo
}
func NewStatsHandler(stats *repository.StatsRepo, feedback *repository.FeedbackRepo) *StatsHandler {
return &StatsHandler{stats: stats, feedback: feedback}
}
func (h *StatsHandler) Render(w http.ResponseWriter, r *http.Request) {
snap, err := h.stats.Snapshot(r.Context())
if err != nil {
http.Error(w, "stats query failed: "+err.Error(), http.StatusInternalServerError)
return
}
recent, err := h.feedback.Recent(r.Context(), 25)
if err != nil {
http.Error(w, "recent feedback query failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Caching off — this is an ops snapshot; staleness defeats the point.
w.Header().Set("Cache-Control", "no-store")
if err := statsTpl.Execute(w, map[string]any{
"Stats": snap,
"Feedback": recent,
"GeneratedAt": time.Now().Format("2006-01-02 15:04:05 MST"),
}); err != nil {
// Headers already flushed — best we can do is bail mid-stream.
return
}
}
var statsTpl = template.Must(template.New("stats").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
}).Parse(statsHTML))
const statsHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stats · Prabhupāda Says</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; max-width: 920px; margin: 2.5rem auto; padding: 0 1.5rem; color: #1a1a1a; line-height: 1.5 }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem }
h2 { font-size: 1.05rem; margin-top: 2rem; margin-bottom: 0.5rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.25rem }
.meta { color: #888; font-size: 0.8rem; margin-bottom: 1.5rem }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem }
.card { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem }
.card .num { font-size: 1.4rem; font-weight: 600; line-height: 1.1 }
.card .num .delta { font-size: 0.75rem; font-weight: 500; color: #1e7a4a; margin-left: 0.4rem }
.card .label { color: #666; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.35rem }
.row { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #f0f0f0; font-size: 0.92rem }
.row:last-child { border-bottom: 0 }
.row .cat { font-weight: 600; text-transform: capitalize }
.fb { background: #fafafa; border: 1px solid #ececec; border-radius: 8px; padding: 0.9rem 1rem; margin-bottom: 0.6rem }
.fb .head { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.82rem; color: #666; margin-bottom: 0.4rem; flex-wrap: wrap }
.fb .cat-pill { font-weight: 700; text-transform: capitalize; color: #1a1a1a }
.fb .msg { white-space: pre-wrap; font-size: 0.95rem }
.fb .author { font-size: 0.78rem; color: #888; margin-top: 0.5rem }
code { background: #f4f4f4; padding: 0.05em 0.3em; border-radius: 3px; font-size: 0.85em }
.empty { color: #888; font-style: italic }
</style>
</head>
<body>
<h1>System snapshot</h1>
<p class="meta">Generated {{.GeneratedAt}}</p>
<h2>Users &amp; activity</h2>
<div class="grid">
<div class="card"><div class="label">Users</div><div class="num">{{.Stats.Users}}<span class="delta">+{{.Stats.UsersLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Posts</div><div class="num">{{.Stats.Posts}}<span class="delta">+{{.Stats.PostsLast7Days}} / 7d</span></div></div>
<div class="card"><div class="label">Collections</div><div class="num">{{.Stats.Collections}}<span class="delta">{{.Stats.PublicCollections}} public</span></div></div>
<div class="card"><div class="label">Bookmarks</div><div class="num">{{.Stats.Bookmarks}}</div></div>
<div class="card"><div class="label">Reshares</div><div class="num">{{.Stats.Reshares}}</div></div>
<div class="card"><div class="label">Feedback</div><div class="num">{{.Stats.Feedback}}<span class="delta">+{{.Stats.FeedbackLast7Days}} / 7d</span></div></div>
</div>
<h2>Feedback by category</h2>
{{if .Stats.FeedbackByCategory}}
{{range .Stats.FeedbackByCategory}}
<div class="row"><span class="cat">{{.Category}}</span><span>{{.Count}}</span></div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
<h2>Recent feedback</h2>
{{if .Feedback}}
{{range .Feedback}}
<div class="fb">
<div class="head">
<span class="cat-pill">{{.Category}}</span>
<span>{{fmtTime .CreatedAt}}</span>
</div>
<div class="msg">{{.Message}}</div>
<div class="author">
{{if .AuthorHandle}}
@{{.AuthorHandle}}{{if .AuthorName}} · {{.AuthorName}}{{end}}
{{else if .ContactEmail}}
anonymous · <code>{{.ContactEmail}}</code>
{{else}}
anonymous
{{end}}
</div>
</div>
{{end}}
{{else}}
<p class="empty">No feedback yet.</p>
{{end}}
</body>
</html>`