Merge phase/02 into milestone/v0.6-nomad-web-ui (P2 complete → v0.5.2)
---ci--- project: oy phase: 2 milestone: v0.6 status: complete requirements: covered: [REQ-040, REQ-041, REQ-045] ---/ci---
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 2,
|
||||||
"stage": "complete",
|
"stage": "complete",
|
||||||
"milestone": "v0.6",
|
"milestone": "v0.6",
|
||||||
"milestone_type": "feature",
|
"milestone_type": "feature",
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"phase_role": "execution",
|
"phase_role": "execution",
|
||||||
"project": "oy",
|
"project": "oy",
|
||||||
"attempts": 0,
|
"attempts": 0,
|
||||||
"updated_at": "2026-08-18T13:55:00Z",
|
"updated_at": "2026-08-18T14:05:00Z",
|
||||||
"milestone_complete": false,
|
"milestone_complete": false,
|
||||||
"requirements_covered": ["REQ-040", "REQ-045"]
|
"requirements_covered": ["REQ-040", "REQ-041", "REQ-045"]
|
||||||
}
|
}
|
||||||
+11
-2
@@ -33,8 +33,16 @@ type Server struct {
|
|||||||
// New constructs a Server with the given store + per-page templates loaded
|
// New constructs a Server with the given store + per-page templates loaded
|
||||||
// from templatesDir (the absolute or relative path to web/templates/).
|
// from templatesDir (the absolute or relative path to web/templates/).
|
||||||
func New(s *store.Store, templatesDir string) (*Server, error) {
|
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")
|
basePath := filepath.Join(templatesDir, "base.html")
|
||||||
base, err := template.ParseFiles(basePath)
|
base, err := template.New("base.html").Funcs(funcs).ParseFiles(basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parse base: %w", err)
|
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.
|
// patterns). Called by web/server.go after constructing the Server.
|
||||||
func (s *Server) Register(mux *http.ServeMux) {
|
func (s *Server) Register(mux *http.ServeMux) {
|
||||||
s.registerReach(mux)
|
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
|
// render executes the named page template with the given data, writing HTML
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
Reference in New Issue
Block a user