package main
import (
"cmp"
_ "embed"
"html/template"
"log"
"net/http"
"strings"
"time"
"github.com/kami/maven/internal/ipc"
)
// The read-only pages: dash, history, trace, morning, events, and the voice
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
// The write surfaces live next to their own handlers (tasks.go, tools.go,
// routines.go, chat.go).
//go:embed dash.html
var dashHTML string
//go:embed history.html
var historyHTML string
//go:embed trace.html
var traceHTML string
//go:embed morning.html
var morningHTML string
//go:embed events.html
var eventsHTML string
//go:embed voice.html
var voiceHTML string
//go:embed ecosystem.html
var ecosystemHTML string
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
// a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = parsePage("dash", dashHTML, nil)
var historyTmpl = parsePage("history", historyHTML, nil)
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
"fmtTime": func(t *time.Time) string {
if t == nil || t.IsZero() {
return "—"
}
return t.Format("15:04:05")
},
"join": strings.Join,
})
// morningTmpl — read-only view of today's checklist state per configured
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
var morningTmpl = parsePage("morning", morningHTML, nil)
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
var eventsTmpl = parsePage("events", eventsHTML, nil)
var voiceTmpl = parsePage("voice", voiceHTML, nil)
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
// human surface is here (they ship no web UI of their own).
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "dash") {
return
}
ctx := r.Context()
pres, err1 := core.Presence(ctx)
facts, err2 := core.RecentFacts(ctx, 50)
nudges, err3 := core.RecentNudges(ctx, 50)
notes, err4 := core.RecentNotes(ctx, 50)
if err := cmp.Or(err1, err2, err3, err4); err != nil {
log.Printf("dash: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
renderPage(w, dashTmpl, struct {
Presence ipc.Presence
Facts []ipc.Fact
Nudges []ipc.Nudge
Notes []ipc.Note
}{pres, facts, nudges, notes})
}
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "history") {
return
}
facts, err := core.RecentFacts(r.Context(), 200)
if err != nil {
log.Printf("history: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
renderPage(w, historyTmpl, struct {
Facts []ipc.Fact
}{facts})
}
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "trace") {
return
}
trace, err := core.TickTrace(r.Context())
if err != nil {
log.Printf("trace: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
// The turn records share this page rather than getting one of their own
// (V-564). Both answer the same question, who won and who lost and why, and
// one is about nudges while the other is about utterances. A read failure
// here is not fatal to the page. The rule trace above it still renders, and
// a daemon too old to know the method is the ordinary case during a rolling
// deploy.
turns, err := core.TurnDecisions(r.Context(), 25)
if err != nil {
log.Printf("trace: turn decisions: %v", err)
}
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
}
// traceData — what trace.html renders: the last tick's rule arbitration and the
// last turns' claim arbitration.
type traceData struct {
Tick ipc.TickTrace
Turns []ipc.TurnDecision
}
// morningView — what /morning renders: today's plan on top, the checklist
// state under it. PlanErr is set instead of Plan when the core could not build
// a plan, so the page says so rather than showing an empty day.
type morningView struct {
Plan *ipc.DayPlan
PlanErr string
Routines []ipc.MorningRoutineStatus
}
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "morning") {
return
}
ctx := r.Context()
status, err := core.MorningStatus(ctx)
if err != nil {
log.Printf("morning: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
view := morningView{Routines: status}
// The day plan (#128) shows on this page because it is the same question at
// a different scale. A plan read that fails must not take the checklist
// down with it — the page degrades to what it had before.
plan, err := core.DayPlan(ctx)
if err != nil {
log.Printf("morning: day plan: %v", err)
view.PlanErr = err.Error()
} else {
view.Plan = &plan
}
renderPage(w, morningTmpl, view)
}
// eventsView — what /events renders. Err is set instead of Events when the
// core could not serve the journal, so the page says why rather than showing an
// empty intake and implying nothing arrived.
type eventsView struct {
Events []ipc.IntakeEvent
Err string
}
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
// page is for scanning what just happened, not for archaeology.
const eventsPageLimit = 200
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if !requireCore(w, core, "intake journal") {
return
}
var view eventsView
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
if err != nil {
log.Printf("events: %v", err)
view.Err = err.Error()
} else {
view.Events = evs
}
renderPage(w, eventsTmpl, view)
}
func handleVoice(w http.ResponseWriter, r *http.Request) {
renderPage(w, voiceTmpl, nil)
}