Merge phase/02 into milestone/v0.6-nomad-web-ui (P2 complete → v0.5.2)
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled

---ci---
project: oy
phase: 2
milestone: v0.6
status: complete
requirements:
  covered: [REQ-040, REQ-041, REQ-045]
---/ci---
This commit is contained in:
2026-08-18 18:55:11 +00:00
5 changed files with 295 additions and 6 deletions
+4 -4
View File
@@ -1,5 +1,5 @@
{
"phase": 1,
"phase": 2,
"stage": "complete",
"milestone": "v0.6",
"milestone_type": "feature",
@@ -7,7 +7,7 @@
"phase_role": "execution",
"project": "oy",
"attempts": 0,
"updated_at": "2026-08-18T13:55:00Z",
"updated_at": "2026-08-18T14:05:00Z",
"milestone_complete": false,
"requirements_covered": ["REQ-040", "REQ-045"]
}
"requirements_covered": ["REQ-040", "REQ-041", "REQ-045"]
}
+11 -2
View File
@@ -33,8 +33,16 @@ type Server struct {
// 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
},
}
basePath := filepath.Join(templatesDir, "base.html")
base, err := template.ParseFiles(basePath)
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
if err != nil {
return nil, fmt.Errorf("parse base: %w", err)
}
@@ -66,7 +74,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
// patterns). Called by web/server.go after constructing the Server.
func (s *Server) Register(mux *http.ServeMux) {
s.registerReach(mux)
// P2..P5 register their own routes (stash, window, standing, bloom).
s.registerStash(mux)
// P3..P5 register their own routes (window, standing, bloom).
}
// render executes the named page template with the given data, writing HTML
+68
View File
@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
breadtypes "github.com/oy/openyield/x/bread/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
// registerStash wires the Stash dashboard route (REQ-041) into the mux.
func (s *Server) registerStash(mux *http.ServeMux) {
mux.HandleFunc("GET /stash/{holderID}", s.handleStashDashboard)
}
// stashViewData is the template data for the Stash dashboard. It carries the
// real x/*/types structs plus the Bread-scale conversion (computed from the
// x/bread/types code constants per D-073) and the maturity progress.
type stashViewData struct {
Stash stashtypes.Stash
Activity stashtypes.StashActivity
Found bool
BreadScale []breadtypes.BreadScale
BalanceBread int64
MaturityPct int
Mature bool
ThresholdDays uint32
MaxGapDays uint32
}
// handleStashDashboard renders the Stash dashboard (REQ-041): balance in Grain
// + Bread-scale conversion (using x/bread/types.BreadScaleAll() + GrainsPerBread
// per D-073 — code constants, NOT docs) + 90-day maturity progress bar
// (StashActivity.IsMature, MaturityThresholdDays=90).
func (s *Server) handleStashDashboard(w http.ResponseWriter, r *http.Request) {
holderID := r.PathValue("holderID")
stash, ok := s.Store.GetStash(holderID)
if !ok {
http.NotFound(w, r)
return
}
activity, _ := s.Store.GetStashActivity(stash.StashID)
// D-073: Bread-scale conversion from x/bread/types code constants.
scale := breadtypes.BreadScaleAll()
balanceBread := stash.BalanceGrain / breadtypes.GrainsPerBread
// Maturity progress: ActiveDays / MaturityThresholdDays, capped at 100%.
threshold := uint32(stashtypes.MaturityThresholdDays)
pct := int(float64(activity.ActiveDays) / float64(threshold) * 100)
if pct > 100 {
pct = 100
}
if pct < 0 {
pct = 0
}
s.render(w, "stash.html", stashViewData{
Stash: stash,
Activity: activity,
Found: true,
BreadScale: scale,
BalanceBread: balanceBread,
MaturityPct: pct,
Mature: activity.IsMature(),
ThresholdDays: threshold,
MaxGapDays: stashtypes.MaxGapForMaturity,
})
}
+156
View File
@@ -0,0 +1,156 @@
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
breadtypes "github.com/oy/openyield/x/bread/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
func TestStashDashboardSeededMatureHolder(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /stash/holder-alia: status %d, want 200", rec.Code)
}
body := rec.Body.String()
// Balance in Grain present.
if !strings.Contains(body, "Grain") {
t.Errorf("body missing 'Grain' balance")
}
// Bread-scale conversion table present (all 11 denominations from BreadScaleAll).
for _, ds := range breadtypes.BreadScaleAll() {
if !strings.Contains(body, ds.Name) {
t.Errorf("body missing Bread-scale denomination %q", ds.Name)
}
}
// Mature holder (ActiveDays=92, MaxGap=10): progress ~100%, Mature badge.
if !strings.Contains(body, "Mature") {
t.Errorf("body missing 'Mature' badge for mature holder-alia")
}
assertNoBannedTerms(t, body)
}
func TestStashDashboardImmatureHolder(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/stash/holder-bryn", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /stash/holder-bryn: status %d, want 200", rec.Code)
}
body := rec.Body.String()
// Immature holder (ActiveDays=45, MaxGap=5): Not mature badge.
if !strings.Contains(body, "Not mature") {
t.Errorf("body missing 'Not mature' badge for immature holder-bryn")
}
// Progress bar at 50% (45/90).
if !strings.Contains(body, "50%") {
t.Errorf("body missing 50%% progress for holder-bryn (45/90 days)")
}
assertNoBannedTerms(t, body)
}
func TestStashDashboardMissingReturns404(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/stash/nobody", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("GET /stash/nobody: status %d, want 404", rec.Code)
}
// G-026: rendered-HTML lexicon check on the ERROR response body too.
assertNoBannedTerms(t, rec.Body.String())
}
// TestStashBreadScaleConversionCorrectness (D-073 regression guard): asserts
// the Stash dashboard uses x/bread/types code constants (GrainsPerBread=10000,
// BreadScaleAll() with Grain=1, Crumb=100, Bread=10000...), NOT the outdated
// docs/shared/bread-scale.md (which claims 1,000x ratios). This test would FAIL
// if the handler hardcoded the docs values instead of using the code constants.
func TestStashBreadScaleConversionCorrectness(t *testing.T) {
srv := newTestServer(t)
mux := http.NewServeMux()
srv.Register(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/stash/holder-alia", nil)
mux.ServeHTTP(rec, req)
body := rec.Body.String()
// D-073: the code constants are the source of truth.
// GrainsPerBread must be 10000 (code), NOT 1000 (docs claim 1 Crumb=1000 Grain).
if breadtypes.GrainsPerBread != 10000 {
t.Fatalf("D-073: x/bread/types.GrainsPerBread = %d, want 10000 (code constant)", breadtypes.GrainsPerBread)
}
// The handler computes BalanceBread = BalanceGrain / GrainsPerBread.
// holder-alia seed: BalanceGrain = 920000 -> 92 Bread.
stash, ok := srv.Store.GetStash("holder-alia")
if !ok {
t.Fatal("seeded holder-alia stash missing")
}
wantBread := stash.BalanceGrain / breadtypes.GrainsPerBread
wantBreadStr := []byte(formatInt(wantBread))
if !strings.Contains(body, string(wantBreadStr)) {
t.Errorf("D-073: body missing expected Bread conversion %d (from %d Grain / %d GrainsPerBread)",
wantBread, stash.BalanceGrain, breadtypes.GrainsPerBread)
}
// The Bread-scale table must include the code-constant Grain values.
scale := breadtypes.BreadScaleAll()
for _, ds := range scale {
if !strings.Contains(body, formatInt(ds.GrainValue)) {
t.Errorf("D-073: body missing Bread-scale GrainValue %d for %s", ds.GrainValue, ds.Name)
}
}
// Regression guard: if someone used the outdated docs value (1 Crumb = 1000
// Grain) instead of the code constant (1 Crumb = 100 Grain), the Crumb row
// would show 1000 — assert it shows 100 (the code value).
crumbs := scale[1] // index 1 = Crumb
if crumbs.Name != "Crumb" || crumbs.GrainValue != 100 {
t.Fatalf("D-073: BreadScaleAll()[1] = {%s, %d}, want {Crumb, 100}", crumbs.Name, crumbs.GrainValue)
}
if !strings.Contains(body, "100") {
t.Errorf("D-073: body missing code-constant Crumb=100 Grain (would show 1000 if docs values were used)")
}
}
// Compile-time assertions that the handler uses the real x/*/types structs
// (D-067: the UI grounds in the real Go type definitions).
var _ stashtypes.Stash
var _ stashtypes.StashActivity
// formatInt is a tiny strconv.Itoa without the import (keeps test deps minimal).
func formatInt(n int64) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+56
View File
@@ -0,0 +1,56 @@
{{define "title"}}Stash — OpenYield{{end}}
{{define "content"}}
<section class="panel">
<h1>Stash</h1>
<p>A Stash is a Holder's personal storage — the place a Nomad holds Grain.
It is a storage layer, not a custodial position: the Holder owns it,
controls it, and can delegate a scoped, time-limited Window to a partner
without giving up custody.</p>
</section>
<section class="panel">
<h2>Balance</h2>
<table class="kv">
<tr><th>Stash ID</th><td>{{.Stash.StashID}}</td></tr>
<tr><th>Holder ID</th><td>{{.Stash.HolderID}}</td></tr>
<tr><th>Balance</th><td>{{.Stash.BalanceGrain}} Grain ({{.BalanceBread}} Bread)</td></tr>
<tr><th>Created</th><td>{{.Stash.CreatedAt}}</td></tr>
<tr><th>Last active</th><td>{{.Stash.LastActive}}</td></tr>
<tr><th>Still</th><td>{{if .Stash.IsStill}}paused{{else}}active{{end}}</td></tr>
</table>
</section>
<section class="panel">
<h2>Bread scale</h2>
<p>1 Bread = 10,000 Grain. The full scale (from the protocol code constants):</p>
<table>
<thead><tr><th>Denomination</th><th>Grain value</th><th>Equivalent in this Stash</th></tr></thead>
<tbody>
{{range .BreadScale}}
<tr>
<td>{{.Name}}</td>
<td>{{.GrainValue}}</td>
<td>{{if eq .Name "Grain"}}{{$.Stash.BalanceGrain}}{{else}}{{divGrain $.Stash.BalanceGrain .GrainValue}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel">
<h2>Maturity progress</h2>
<p>Holding a Stash continuously for 90 days is the first of the four
Freeholder signals. The signal is about continuity, not size.</p>
<div class="progress-track">
<div class="progress-bar" style="width: {{.MaturityPct}}%">{{.MaturityPct}}%</div>
</div>
<table class="kv">
<tr><th>Active days</th><td>{{.Activity.ActiveDays}} / {{.ThresholdDays}}</td></tr>
<tr><th>Max gap days</th><td>{{.Activity.MaxGapDays}} / {{.MaxGapDays}} (max allowed)</td></tr>
<tr><th>Mature</th><td>{{if .Mature}}<span class="badge green">Mature</span>{{else}}<span class="badge amber">Not mature</span>{{end}}</td></tr>
</table>
</section>
<p><a href="/reach/{{.Stash.HolderID}}">Back to Reach</a></p>
{{end}}