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) }