131 lines
5.5 KiB
Go
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 & 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>`
|