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

+ + + + {{range .BreadScale}} + + + + + + {{end}} + +
DenominationGrain valueEquivalent in this Stash
{{.Name}}{{.GrainValue}}{{if eq .Name "Grain"}}{{$.Stash.BalanceGrain}}{{else}}{{divGrain $.Stash.BalanceGrain .GrainValue}}{{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.

+
+
{{.MaturityPct}}%
+
+ + + + +
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