Files
openyield/web/handlers/server.go
T
cloudinit-bot 451ea08414 feat(P4): Standing + Freeholder signals progress (REQ-043)
web/handlers/standing.go: GET /standing/{reachID} renders standing score
+ bucket + 4-signal progress + Freeholder-eligible badge. Store extensions:
ListRatings/ListVouches/ListSlashes/ComputeStandingScore (simplified, from
locked x/standing/types constants PriorMean/PriorWeight/ComputeDiversityBonus/
GetVoucherWeight/GetStandingBucket — NOT hardcoded)/ComputeFreeholderSignals
(real FreeholderSignals struct + IsFreeholderEligible). Seed: holder-alia
(12 ratings, 4 cats, 1 Vouch, mature Stash -> eligible) vs holder-bryn
(3 ratings, 1 cat, no Vouch, immature -> not eligible). standing_test.go:
computed-from-locked-constants regression guard (PriorMean=4.0, PriorWeight=
10, DiversityBonus4Cats=0.10), Freeholder-eligible badge reflects
IsFreeholderEligible() (the real method), G-026 error lexicon check.
Coverage: store 98.0%, handlers 88.7%.

---ci---
project: oy
phase: 4
milestone: v0.6
status: execute
---/ci---
2026-08-18 19:02:44 +00:00

126 lines
4.0 KiB
Go

// Package handlers holds the HTTP handlers for the OpenYield web UI screens.
//
// Each screen (Reach signup, Stash dashboard, Window authorization, Standing
// progress, Bloom accrual) gets its own handler file. handlers/server.go wires
// routes into the mux from web/server.go. Handlers render html/template
// templates against the mock store (web/store). Lexicon-clean by construction
// (REQ-012 / REQ-045): the lexicon_meta_web firewall scans these files.
package handlers
import (
"fmt"
"html/template"
"net/http"
"os"
"path/filepath"
"github.com/oy/openyield/web/store"
standingtypes "github.com/oy/openyield/x/standing/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
// Server bundles the mock store + per-page templates + route registration.
// Each screen handler is a method on Server so it shares the store + tmpl.
//
// Template loading: base.html is parsed once, then each page template is
// parsed in a CLONE of the base set so the per-page "content" block does not
// collide across pages (Go html/template shares the block namespace within
// one set; cloning per page isolates each page's content block). This is the
// standard Go template pattern for layouts + pages.
type Server struct {
Store *store.Store
Pages map[string]*template.Template
}
// New constructs a Server with the given store + per-page templates loaded
// from templatesDir (the absolute or relative path to web/templates/).
func New(s *store.Store, templatesDir string) (*Server, error) {
funcs := template.FuncMap{
"divGrain": func(grain, unit int64) int64 {
if unit == 0 {
return 0
}
return grain / unit
},
"string": func(v any) string {
switch t := v.(type) {
case string:
return t
case windowtypes.WindowStatus:
return string(t)
case standingtypes.StandingBucket:
return string(t)
default:
return ""
}
},
}
basePath := filepath.Join(templatesDir, "base.html")
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
if err != nil {
return nil, fmt.Errorf("parse base: %w", err)
}
pages := map[string]*template.Template{}
pageGlob := filepath.Join(templatesDir, "*.html")
matches, err := filepath.Glob(pageGlob)
if err != nil {
return nil, fmt.Errorf("glob pages: %w", err)
}
for _, p := range matches {
name := filepath.Base(p)
if name == "base.html" {
continue
}
clone, cerr := base.Clone()
if cerr != nil {
return nil, fmt.Errorf("clone for %s: %w", name, cerr)
}
pt, perr := clone.ParseFiles(p)
if perr != nil {
return nil, fmt.Errorf("parse %s: %w", name, perr)
}
pages[name] = pt
}
return &Server{Store: s, Pages: pages}, nil
}
// Register wires all screen routes into the given mux (Go 1.22 method
// patterns). Called by web/server.go after constructing the Server.
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
s.registerStash(mux)
s.registerWindow(mux)
s.registerStanding(mux)
// P5 registers bloom.
}
// render executes the named page template with the given data, writing HTML
// to w. The page template invokes base.html and overrides the "content" block.
func (s *Server) render(w http.ResponseWriter, name string, data any) {
tmpl, ok := s.Pages[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base.html", data); err != nil {
http.Error(w, "render error", http.StatusInternalServerError)
}
}
// RenderHome renders the home page (public so web/server.go can call it for
// the "/" route which lives outside handlers.Register).
func (s *Server) RenderHome(w http.ResponseWriter, data any) {
s.render(w, "home.html", data)
}
// templatesDir returns the default web/templates directory relative to the
// working directory. Used by web/server.go when constructing via New().
func DefaultTemplatesDir() string {
dir, _ := os.Getwd()
if filepath.Base(dir) == "web" || filepath.Base(dir) == "handlers" {
return filepath.Join(dir, "templates")
}
return "web/templates"
}