diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json
index 0e5acaa..686b8f2 100644
--- a/.ciagent/CHECKPOINT.json
+++ b/.ciagent/CHECKPOINT.json
@@ -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"]
-}
\ No newline at end of file
+ "requirements_covered": ["REQ-040", "REQ-041", "REQ-045"]
+}
diff --git a/web/handlers/server.go b/web/handlers/server.go
index b9eafec..deb6c39 100644
--- a/web/handlers/server.go
+++ b/web/handlers/server.go
@@ -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
diff --git a/web/handlers/stash.go b/web/handlers/stash.go
new file mode 100644
index 0000000..f9fff61
--- /dev/null
+++ b/web/handlers/stash.go
@@ -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,
+ })
+}
diff --git a/web/handlers/stash_test.go b/web/handlers/stash_test.go
new file mode 100644
index 0000000..fc8d895
--- /dev/null
+++ b/web/handlers/stash_test.go
@@ -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:])
+}
diff --git a/web/templates/stash.html b/web/templates/stash.html
new file mode 100644
index 0000000..300ab51
--- /dev/null
+++ b/web/templates/stash.html
@@ -0,0 +1,56 @@
+{{define "title"}}Stash — OpenYield{{end}}
+
+{{define "content"}}
+
+ Stash
+ 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.
+
+
+
+ Balance
+
+ | Stash ID | {{.Stash.StashID}} |
+ | Holder ID | {{.Stash.HolderID}} |
+ | Balance | {{.Stash.BalanceGrain}} Grain ({{.BalanceBread}} Bread) |
+ | Created | {{.Stash.CreatedAt}} |
+ | Last active | {{.Stash.LastActive}} |
+ | Still | {{if .Stash.IsStill}}paused{{else}}active{{end}} |
+
+
+
+
+ Bread scale
+ 1 Bread = 10,000 Grain. The full scale (from the protocol code constants):
+
+ | Denomination | Grain value | Equivalent in this Stash |
+
+ {{range .BreadScale}}
+
+ | {{.Name}} |
+ {{.GrainValue}} |
+ {{if eq .Name "Grain"}}{{$.Stash.BalanceGrain}}{{else}}{{divGrain $.Stash.BalanceGrain .GrainValue}}{{end}} |
+
+ {{end}}
+
+
+
+
+
+ Maturity progress
+ Holding a Stash continuously for 90 days is the first of the four
+ Freeholder signals. The signal is about continuity, not size.
+
+
+ | Active days | {{.Activity.ActiveDays}} / {{.ThresholdDays}} |
+ | Max gap days | {{.Activity.MaxGapDays}} / {{.MaxGapDays}} (max allowed) |
+ | Mature | {{if .Mature}}Mature{{else}}Not mature{{end}} |
+
+
+
+
Back to Reach
+{{end}}
\ No newline at end of file