From 9a8ea8f31ea18f89d63c4f4f541a530bec2d0a8f Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 29 May 2026 12:07:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(views):=20Phase=205j=20slice=20G=20?= =?UTF-8?q?=E2=80=94=20show=5Fcount=20badges=20+=20icon=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per m's v1 picks (2026-05-29): - Q6 (icon picker): yes, with curated keys + SVG registry. - Q8 (show_count badge): yes, opt-in checkbox + sidebar badge. Icon registry (web/icons.go): - 7 curated keys: folder (default), clock, star, tag, inbox, box, file-text. Each maps to a Feather-style 24x24 SVG matching the rest of the projax sidebar aesthetic. Returns template.HTML so layout.tmpl emits markup verbatim. Unknown / nil keys fall back to folder. - RenderViewIcon(*string) is template-callable; IconRegistryKeys() feeds the editor's now sourced from IconKeys data (the editor handler passes IconRegistryKeys()). CSS additions: - nav-badge: muted-color, surface-background, pill-shaped, pushed to the right via margin-left:auto so the badge aligns with the row's end regardless of name length. - nav-item-user-view.active .nav-badge: switches to accent border + color so the active row's badge stays legible. Tests: - TestSidebarShowCountBadge — seeds show_count=true view, asserts .nav-badge markup in the sidebar. - TestSidebarIconRenders — seeds icon=star view, asserts the distinctive star polygon path lands in the sidebar SVG. Drag-reorder UI stays parked (m's Q7=(b) v2). sort_order column is server-assigned MAX+1 on create; the column was wired in slice A and ReorderViews is ready for slice G's followup. --- web/icons.go | 43 +++++++++++++++++++++++++++++++ web/server.go | 36 ++++++++++++++++++++++++++ web/static/style.css | 8 ++++++ web/system_views_test.go | 47 ++++++++++++++++++++++++++++++++++ web/templates/layout.tmpl | 7 ++--- web/templates/view_editor.tmpl | 10 +++----- web/views.go | 1 + 7 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 web/icons.go diff --git a/web/icons.go b/web/icons.go new file mode 100644 index 0000000..7280453 --- /dev/null +++ b/web/icons.go @@ -0,0 +1,43 @@ +package web + +import "html/template" + +// Phase 5j slice G — icon registry per m's Q6 pick (2026-05-29). The +// curated set of keys mirrors the editor's . The first key (folder) is the default. +func IconRegistryKeys() []string { + return []string{"folder", "clock", "star", "tag", "inbox", "box", "file-text"} +} diff --git a/web/server.go b/web/server.go index 1595472..23a7e69 100644 --- a/web/server.go +++ b/web/server.go @@ -132,6 +132,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { "addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) }, "subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) }, "mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) }, + // Phase 5j slice G — sidebar icon registry. layout.tmpl calls + // `renderIcon .View.Icon` to emit the matching SVG, falling back to + // the folder default for nil / unknown keys. + "renderIcon": RenderViewIcon, "tagToggleURL": func(active []string, tag string, isActive bool) string { next := []string{} if isActive { @@ -1017,6 +1021,38 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil { if uv, err := s.Store.ListViews(r.Context()); err == nil { data["UserViews"] = uv + // Phase 5j slice G — show_count badges. For every view with + // ShowCount=true, run its persisted filter against ListAll and + // pass a slug→count map to the template. Caching is one + // ListAll per render shared across all show-count views. + counts := map[string]int{} + needsCount := false + for _, v := range uv { + if v.ShowCount { + needsCount = true + break + } + } + if needsCount { + items, err := s.Store.ListAll(r.Context()) + if err == nil { + linkKinds, _ := s.linkKindsByItem(r.Context()) + for _, v := range uv { + if !v.ShowCount { + continue + } + f, _, _ := decodeViewSpec(v.FilterJSON) + n := 0 + for _, it := range items { + if f.Matches(it, linkKinds[it.ID]) { + n++ + } + } + counts[v.Slug] = n + } + } + } + data["UserViewCounts"] = counts } } entry := "layout" diff --git a/web/static/style.css b/web/static/style.css index 2fd7898..42ef508 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -1220,6 +1220,14 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label { .projax-sidebar .nav-item-user-view.active { padding-left: 22px; } .projax-sidebar .user-view-icon { width: 1em; text-align: center; } .projax-sidebar .nav-item-new-view { color: var(--muted); } +.projax-sidebar .nav-badge { + margin-left: auto; font-size: 0.78em; color: var(--muted); + background: var(--surface); border: 1px solid var(--border); + border-radius: 10px; padding: 0 6px; +} +.projax-sidebar .nav-item-user-view.active .nav-badge { + color: var(--accent); border-color: var(--accent); +} .projax-sidebar .nav-icon { width: 18px; height: 18px; diff --git a/web/system_views_test.go b/web/system_views_test.go index d236336..e3bbca7 100644 --- a/web/system_views_test.go +++ b/web/system_views_test.go @@ -99,6 +99,53 @@ VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil { } } +// TestSidebarShowCountBadge — slice G: a saved view with show_count=true +// renders a row-count badge in the sidebar reflecting the filter's match +// count against ListAll(). +func TestSidebarShowCountBadge(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + ctx := context.Background() + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "") + slug := "p5j-g-badge-" + stamp + defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug) + // Seed a view scoped to dev → its count = count of items under dev that + // match status=active (default). + if _, err := pool.Exec(ctx, ` +INSERT INTO projax.views (slug, name, filter_json, show_count) +VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`, + slug); err != nil { + t.Fatalf("seed view: %v", err) + } + _, body := get(t, h, "/views/tree") + if !strings.Contains(body, `class="nav-badge"`) { + t.Error("show_count view should render a nav-badge in the sidebar") + } +} + +// TestSidebarIconRenders — slice G: a view with an icon key emits the +// SVG from the registry; missing key falls back to folder default. +func TestSidebarIconRenders(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + ctx := context.Background() + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "") + slug := "p5j-g-icon-" + stamp + defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug) + if _, err := pool.Exec(ctx, ` +INSERT INTO projax.views (slug, name, filter_json, icon) +VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil { + t.Fatalf("seed: %v", err) + } + _, body := get(t, h, "/views/tree") + // The star icon's SVG path includes its distinctive 5-point polygon. + if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) { + t.Error("sidebar should render the star icon SVG for icon=star") + } +} + // TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay // `?view=` param, the redirect resolves the uuid to the current // slug (per m's Q3 pick), so old bookmarks land on the right user view. diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl index b153f70..962c299 100644 --- a/web/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -90,15 +90,16 @@ Views {{if .UserViews}} + {{$counts := .UserViewCounts}}