From b9161eba17468968ca78ff211a5cbfbc0f4b00f2 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 13:50:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(views):=20Phase=205i=20slice=20E=20?= =?UTF-8?q?=E2=80=94=20default=20view-per-page=20+=20opt-out=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the Phase 5i implementation chain. When `views.is_default_for=` is set, opening that page with a "clean" URL (no chip params, no ?view=) auto-applies the saved filter + view_type. A "Showing default view: · clear" banner makes the swap visible and gives the user a one-click out. Adding any chip param to the URL bypasses the default; ?nodefault=1 is the explicit opt-out for "I want the bare default tree". New web/views.go: applyDefaultView gates on the param-cleanness check + Store.DefaultViewFor lookup. Resolution + view_type revalidation mirror the slice D ?view= path so a kanban-default opened on a route that doesn't allow kanban falls back cleanly. handleTree wires it into the existing slice D else-branch (no default when ?view= is set). DefaultBanner field passes the applied view to the template for the banner. Test: - TestDefaultViewAppliedOnCleanURL — seeds a tree default with filter_json={tags:[work]} + view_type=card, then asserts: clean GET / applies (card grid + banner with the view's name); ?tag=dev bypasses (forest, no banner); ?nodefault=1 opt-out (forest, no banner). --- web/server.go | 12 ++++++++ web/static/style.css | 9 ++++++ web/templates/tree_section.tmpl | 7 +++++ web/views.go | 35 +++++++++++++++++++++ web/views_test.go | 54 +++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/web/server.go b/web/server.go index 26eab01..8c13af5 100644 --- a/web/server.go +++ b/web/server.go @@ -450,6 +450,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { filter := ParseTreeFilter(r.URL.Query()) viewSet := PageViewTypes("/") view := ParseViewType(r.URL.Query(), viewSet) + var defaultBanner *store.View // Phase 5i Slice D: ?view= resolves a saved view's filter + // view_type into the current request, overriding URL-only chip state. // Resolution failure (deleted view, malformed payload) is logged and @@ -462,6 +463,16 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { view = viewSet.Resolve(view) } else if err != nil { s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err) + } else { + // Phase 5i Slice E: no explicit ?view= → check for a page default. + // applyDefaultView returns nil unless the URL is "clean" (no chip + // state) AND a default exists for this page. + if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil { + view = viewSet.Resolve(view) + defaultBanner = def + } else if err != nil { + s.Logger.Warn("applyDefaultView", "page", "tree", "err", err) + } } roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds) counts := computeChipCounts(items, filter, linkKinds, tags) @@ -492,6 +503,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) { "Kanban": kanban, "GroupBy": groupBy, "GroupByChips": groupByChips, + "DefaultBanner": defaultBanner, // ActiveTags kept for backwards-compat with the old template path; removed // after the template migrates fully. "ActiveTags": filter.Tags, diff --git a/web/static/style.css b/web/static/style.css index 3d77a57..7f96439 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -259,6 +259,15 @@ table.classify input, table.classify select { width: 100%; } .kanban-card-title:hover { color: var(--accent); } .kanban-card-meta { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; font-size: 0.78em; } .kanban-empty { padding: 24px; } +/* Phase 5i Slice E — default-view banner. Sits above the counts line on + any Views-supporting page when a default view is auto-applied. */ +.default-banner { + background: var(--surface); border: 1px solid var(--border); + border-left: 3px solid var(--accent); + padding: 6px 10px; border-radius: 4px; + font-size: 0.85em; margin: 4px 0 8px; +} +.default-banner a { color: var(--bad); } #tree-filterbar small { opacity: 0.75; margin-left: 2px; } .tree-section .empty { padding: 24px; color: var(--muted); } .tree-section .clear { color: var(--bad); } diff --git a/web/templates/tree_section.tmpl b/web/templates/tree_section.tmpl index 3393d5e..d0eddb2 100644 --- a/web/templates/tree_section.tmpl +++ b/web/templates/tree_section.tmpl @@ -1,5 +1,12 @@ {{define "tree-section"}}
+ {{if .DefaultBanner}} +

+ Showing default view: {{.DefaultBanner.Name}} · + clear +

+ {{end}}

{{.Matched}} / {{.Total}} items match {{if .OrphanN}} · {{.OrphanN}} unclassified mai-managed roots → classify{{end}} diff --git a/web/views.go b/web/views.go index fe540f8..120a6e2 100644 --- a/web/views.go +++ b/web/views.go @@ -210,6 +210,41 @@ func filterQueryToJSON(query string) ([]byte, error) { return json.Marshal(payload) } +// applyDefaultView resolves the saved view marked is_default_for= +// when the request URL carries no filter/view-specific params and the user +// has not opted out via ?nodefault=1. Returns the applied view (for banner +// labelling) or nil when no default exists / was applied. +// +// Per design.md §7 Slice E: defaults are a polish layer. They only kick in +// on a "clean" landing — the moment the user types a chip click, the URL +// gains a filter param and the default no longer auto-applies. Same with +// an explicit ?view=. +func (s *Server) applyDefaultView(r *http.Request, page string, filter *TreeFilter, viewType *string) (*store.View, error) { + q := r.URL.Query() + if q.Get("nodefault") == "1" { + return nil, nil + } + // Any filter-affecting param means "user is driving" — skip the default. + for _, key := range []string{"q", "tag", "mgmt", "status", "has", "show-archived", "public", "project", "project_id", "project_descendants", "view", "view_type", "group_by"} { + if q.Get(key) != "" { + return nil, nil + } + } + v, err := s.Store.DefaultViewFor(r.Context(), page) + if err != nil || v == nil { + return v, err + } + payload := map[string]any{} + if len(v.FilterJSON) > 0 { + if err := json.Unmarshal(v.FilterJSON, &payload); err != nil { + return v, fmt.Errorf("decode default filter_json: %w", err) + } + } + *filter = filterFromJSONPayload(payload) + *viewType = v.ViewType + return v, nil +} + // applySavedView resolves a `?view=` reference and folds the persisted // filter + view_type back into the supplied TreeFilter + view-type slot. // Called by every Views-supporting page handler at the top of their render diff --git a/web/views_test.go b/web/views_test.go index e7723ef..d2e42d4 100644 --- a/web/views_test.go +++ b/web/views_test.go @@ -89,6 +89,60 @@ func TestViewsCRUDRoundTrip(t *testing.T) { } } +// TestDefaultViewAppliedOnCleanURL verifies the Slice E behaviour: when / +// is requested with no chip params and a default view exists for the page, +// the saved filter + view_type apply and a "Showing default view: …" +// banner renders. Adding any chip param (?tag=…) bypasses the default. +// ?nodefault=1 is the explicit opt-out. +func TestDefaultViewAppliedOnCleanURL(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"), ".", "") + name := "p5i-E-default-" + stamp + defer pool.Exec(context.Background(), + `UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name) + + if _, err := pool.Exec(ctx, ` +INSERT INTO projax.views (name, view_type, filter_json, is_default_for) +VALUES ($1, 'card', $2::jsonb, 'tree')`, + name, []byte(`{"tags":["work"]}`)); err != nil { + t.Fatalf("seed default view: %v", err) + } + + // Clean URL: default applies → card view + banner. + _, body := get(t, h, "/") + if !strings.Contains(body, `class="tree-card-grid"`) { + t.Error("clean / should auto-apply default view (card grid expected)") + } + if !strings.Contains(body, `default-banner`) { + t.Error("default-banner should render when a default applies") + } + if !strings.Contains(body, name) { + t.Error("banner should name the applied default view") + } + + // Any chip param bypasses the default → list view (no banner). + _, withChip := get(t, h, "/?tag=dev") + if strings.Contains(withChip, `default-banner`) { + t.Error("default banner should disappear once user types a chip") + } + if !strings.Contains(withChip, `

    `) { + t.Error("?tag=dev should render the forest (default not applied)") + } + + // Explicit opt-out via ?nodefault=1. + _, optOut := get(t, h, "/?nodefault=1") + if strings.Contains(optOut, `default-banner`) { + t.Error("?nodefault=1 should suppress the default banner") + } + if !strings.Contains(optOut, `
      `) { + t.Error("?nodefault=1 should render the forest (default suppressed)") + } +} + // TestSavedViewAppliedOnQueryParam verifies that opening / with ?view= // re-applies the saved filter+view_type. We seed a view tagged work=patents // and assert the rendered tree has the right ProjectChip / chip-on state.