feat(views): Phase 5j slice E — sidebar Views section with user views

The Phase 5j sidebar's Views entry already linked to /views; slice E
extends the section to LIST every saved user view with its name + icon
glyph + active state, plus a "+ New view" shortcut at the bottom. The
system views (Tree / Dashboard / Calendar / Timeline / Graph) stay in
the main nav block above so muscle memory holds.

Render plumbing (web/server.go):
- render() pulls ListViews() into the data map under UserViews when
  the template is not "login" (login renders without layout). Stub
  servers without a real Pool skip cleanly via the name guard.
- One indexed lookup per chrome-bearing render. Slice G can add a
  per-request memoisation if profiling bites.

Template (web/templates/layout.tmpl):
- New "Views" sub-section below the main /views entry. Each user view
  emits as a nav-item-user-view link with icon glyph + name. Active
  marker fires when path == /views/<slug>. Bottom anchor: "+ New view"
  link to /views/new for one-click creation from anywhere.
- The icon-glyph stays a placeholder diamond (◆) in this slice; slice
  G ships the registry SVGs.

CSS (web/static/style.css):
- nav-item-user-view: slightly smaller font, indented 24px so user
  views sit visually under the Views section header.
- nav-item-new-view: muted color to distinguish the action from
  navigation.
- sidebar-user-views: flex column with 2px gap matches the existing
  sidebar's spacing rhythm.

Tests:
- TestSidebarListsUserViews — seeds one view, asserts the sidebar
  surfaces /views/{slug} href + display name + the + New view link.
  Active marker fires on /views/{slug}.
This commit is contained in:
mAi
2026-05-29 12:03:47 +02:00
parent f820fa5830
commit 1f8c626aed
4 changed files with 72 additions and 0 deletions

View File

@@ -1008,6 +1008,17 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
if _, set := data["Path"]; !set {
data["Path"] = r.URL.Path
}
// Phase 5j slice E: layout's "Views" sidebar section lists every
// user view. Lookup is one indexed query per render — at m's scale
// (≤30 saved views) the cost is negligible and dwarfed by the
// dashboard/timeline aggregation cards. The login page bypasses the
// layout entirely so we don't fetch for it; stub servers without a
// configured store also skip cleanly.
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
if uv, err := s.Store.ListViews(r.Context()); err == nil {
data["UserViews"] = uv
}
}
entry := "layout"
switch name {
case "login":

View File

@@ -1211,6 +1211,15 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
border-left: 2px solid var(--accent);
padding-left: 14px;
}
/* Phase 5j slice E — Views sub-section: user-view entries sit below the
main nav items, slightly indented + smaller, so the system rows stay
visually anchored. The Views section header (the "Views" main entry)
is unchanged; this just styles the per-saved-view rows. */
.projax-sidebar .sidebar-user-views { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
.projax-sidebar .nav-item-user-view { font-size: 0.92em; padding-left: 24px; }
.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-icon {
width: 18px;
height: 18px;

View File

@@ -65,6 +65,40 @@ func TestLegacyRedirects(t *testing.T) {
}
}
// TestSidebarListsUserViews — slice E: every chrome-bearing page renders
// the saved-view list under the main nav. Each entry links to
// /views/{slug} with the name as the label. Active state fires when the
// current URL matches.
func TestSidebarListsUserViews(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-e-sidebar-" + 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)
VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
_, body := get(t, h, "/views/tree")
if !strings.Contains(body, `href="/views/`+slug+`"`) {
t.Error("sidebar should list saved view as /views/<slug>")
}
if !strings.Contains(body, "P5jE Sidebar") {
t.Error("sidebar should show saved view's display name")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("sidebar Views section should include a + New view link")
}
// Active state when the URL matches.
_, onView := get(t, h, "/views/"+slug)
if !strings.Contains(onView, `class="nav-item nav-item-user-view active"`) {
t.Error("user-view nav-item should carry .active when its URL is current")
}
}
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
// `?view=<uuid>` param, the redirect resolves the uuid to the current
// slug (per m's Q3 pick), so old bookmarks land on the right user view.

View File

@@ -89,6 +89,24 @@
</svg>
<span class="nav-label">Views</span>
</a>
{{if .UserViews}}
<div class="sidebar-user-views" aria-label="Saved views">
{{range .UserViews}}
<a href="/views/{{.Slug}}"
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
title="{{.Name}}">
<span class="nav-icon user-view-icon" aria-hidden="true">
{{if and .Icon (ne (deref .Icon) "")}}◆{{else}}◆{{end}}
</span>
<span class="nav-label">{{.Name}}</span>
</a>
{{end}}
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
<span class="nav-icon" aria-hidden="true"></span>
<span class="nav-label">New view</span>
</a>
</div>
{{end}}
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>