package web import ( "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "github.com/m/projax/store" ) // Phase 5j paliad-shape views handlers. Slice B introduces the route // family; slices C–G evolve the render, editor, system-views, sidebar, // and polish layers. // // Route table: // GET /views → handleViewsLanding (MRU 302 or shell) // GET /views/{slug} → handleViewRender (saved or system) // GET /views/new → handleViewEditor (blank) // GET /views/{slug}/edit → handleViewEditor (existing) // POST /views → handleViewCreate // POST /views/{slug} → handleViewUpdate // POST /views/{slug}/delete → handleViewDelete // POST /views/reorder → handleViewReorder (slice G — wired now, // used in v2) // handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used // view if any, else render the onboarding shell listing every saved view. func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("nodefault") != "1" { mr, err := s.Store.MostRecentView(r.Context()) if err != nil { s.Logger.Warn("views landing: mru", "err", err) } else if mr != nil { http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound) return } } views, err := s.Store.ListViews(r.Context()) if err != nil { s.fail(w, r, err) return } s.render(w, r, "views_landing", map[string]any{ "Title": "views", "Views": views, }) } // handleViewRender resolves a slug into either a user view (Slice A // schema) or a system view (Slice C), then renders the appropriate // template. The render path also fire-and-forgets a TouchView so the // view climbs the MRU ladder for the next /views landing redirect. // // Slice B implementation: only user views are wired; system views // resolve via LookupSystemView (added in Slice C) and 404 in this slice // when the slug is unknown. func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if slug == "" { http.NotFound(w, r) return } v, err := s.Store.GetView(r.Context(), slug) if errors.Is(err, store.ErrViewNotFound) { http.NotFound(w, r) return } if err != nil { s.fail(w, r, err) return } if err := s.Store.TouchView(r.Context(), slug); err != nil { s.Logger.Warn("touch view", "slug", slug, "err", err) } // Parse the saved spec. filter, viewType, groupBy := decodeViewSpec(v.FilterJSON) // Allow URL chip overlay so chip clicks inside a saved view narrow // further. The page chip URLs round-trip ?view= via the URL anchor // added in slice E's sidebar wiring; here we just respect anything // the user typed in the query. urlFilter := ParseTreeFilter(r.URL.Query()) overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query()) if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" { viewType = raw } if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" { groupBy = raw } s.renderViewPage(w, r, v, filter, viewType, groupBy) } // renderViewPage runs the shared render path for a resolved view (user // view or future system view). Slice B reuses the tree handler's // rendering pieces — list / card / kanban share the tree-section // dispatch shape. Calendar / timeline view_types fall back to list in // slice B; slice D wires their dedicated templates. func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) { items, err := s.Store.ListAll(r.Context()) if err != nil { s.fail(w, r, err) return } tags, err := s.Store.AllTags(r.Context()) if err != nil { s.fail(w, r, err) return } linkKinds, err := s.linkKindsByItem(r.Context()) if err != nil { s.fail(w, r, err) return } viewSet := PageViewTypes("/") if viewType == "" { viewType = viewSet.Default } viewType = viewSet.Resolve(viewType) roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds) base := "/views/" + v.Slug counts := computeChipCounts(items, filter, linkKinds, tags, base) cardItems := flatMatchedItems(items, filter, linkKinds) if groupBy == "" { groupBy = ParseGroupBy(r.URL.Query()) } kanban := BuildKanbanBoard(cardItems, groupBy) groupByChips := GroupByChips(base, filter, groupBy) data := map[string]any{ "Title": v.Name, "View": v, "Roots": roots, "Orphans": orphans, "Total": total, "OrphanN": orphanN, "Matched": matched, "AllTags": tags, "Filter": filter, "Counts": counts, "Projects": parentOptionsFromItems(items), "BasePath": base, "ProjectChipTarget": "#tree-section", "ViewType": viewType, "ViewTypeChips": ViewTypeChips(base, filter, viewType), "CardItems": cardItems, "Kanban": kanban, "GroupBy": groupBy, "GroupByChips": groupByChips, "ActiveTags": filter.Tags, } if r.Header.Get("HX-Request") == "true" { s.render(w, r, "tree_section", data) return } s.render(w, r, "view_render", data) } // handleViewEditor renders the create / edit form. Slice B ships a // minimal placeholder; Slice D rebuilds the form with the chip strip // + slug derivation + icon picker. func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") var ( view *store.View err error title = "new view" ) if slug != "" { view, err = s.Store.GetView(r.Context(), slug) if errors.Is(err, store.ErrViewNotFound) { http.NotFound(w, r) return } if err != nil { s.fail(w, r, err) return } title = "edit " + view.Name } filterQuery := "" currentViewType := "list" if view != nil { f, vt, _ := decodeViewSpec(view.FilterJSON) filterQuery = f.QueryString() if vt != "" { currentViewType = vt } } s.render(w, r, "view_editor", map[string]any{ "Title": title, "View": view, "FilterQuery": filterQuery, "ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban}, "CurrentVT": currentViewType, "GroupByOptions": []string{"", "status", "area", "tag", "management"}, "SortDirOptions": []string{"", "asc", "desc"}, "IconKeys": IconRegistryKeys(), }) } // handleViewCreate accepts the create form POST. func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } in, err := viewInputFromForm(r.PostForm) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } v, err := s.Store.CreateView(r.Context(), in) if err != nil { s.writeViewError(w, err) return } http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther) } // handleViewUpdate accepts the edit form POST. func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } in, err := viewInputFromForm(r.PostForm) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } v, err := s.Store.UpdateView(r.Context(), slug, in) if err != nil { if errors.Is(err, store.ErrViewNotFound) { http.NotFound(w, r) return } s.writeViewError(w, err) return } http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther) } // handleViewDelete soft-… nope. New schema is hard-delete (no // deleted_at). One POST removes the row. func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") if err := s.Store.DeleteView(r.Context(), slug); err != nil { if errors.Is(err, store.ErrViewNotFound) { http.NotFound(w, r) return } s.fail(w, r, err) return } http.Redirect(w, r, "/views", http.StatusSeeOther) } // handleViewReorder takes a comma-separated slug list and applies new // sort_order values. Wired now so slice G's drag UI has a target. func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { s.fail(w, r, err) return } raw := strings.TrimSpace(r.PostForm.Get("slugs")) if raw == "" { http.Error(w, "slugs is required", http.StatusBadRequest) return } slugs := strings.Split(raw, ",") for i, slug := range slugs { slugs[i] = strings.TrimSpace(slug) } if err := s.Store.ReorderViews(r.Context(), slugs); err != nil { s.fail(w, r, err) return } w.WriteHeader(http.StatusNoContent) } // writeViewError maps the typed store errors to friendly HTTP status + // banner copy. Falls back to 400 for anything else. func (s *Server) writeViewError(w http.ResponseWriter, err error) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") switch { case errors.Is(err, store.ErrViewSlugFormat): http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest) case errors.Is(err, store.ErrViewSlugReserved): http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest) case errors.Is(err, store.ErrViewSlugTaken): http.Error(w, "slug already exists — pick a different one", http.StatusConflict) default: http.Error(w, err.Error(), http.StatusBadRequest) } } // viewInputFromForm decodes the create/edit form. Slug + name are // required; the rest defaults sensibly. filter_query is optional and // canonicalises into filter_json on save (URL-query form is what the // editor's chip strip emits in slice D). func viewInputFromForm(form url.Values) (store.ViewInput, error) { in := store.ViewInput{ Slug: strings.TrimSpace(form.Get("slug")), Name: strings.TrimSpace(form.Get("name")), SortField: strings.TrimSpace(form.Get("sort_field")), SortDir: strings.TrimSpace(form.Get("sort_dir")), GroupBy: strings.TrimSpace(form.Get("group_by")), ShowCount: form.Get("show_count") == "1", } if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" { in.Icon = &iconRaw } viewType := strings.TrimSpace(form.Get("view_type")) if viewType == "" { viewType = ViewTypeList } fq := strings.TrimSpace(form.Get("filter_query")) filterJSON, err := encodeFilterToJSON(fq, viewType) if err != nil { return in, fmt.Errorf("filter_query: %w", err) } in.FilterJSON = filterJSON return in, nil } // encodeFilterToJSON turns a URL-query-form filter + view_type into the // canonical filter_json shape stored on the view. view_type lives inside // the JSON per m's Q2 pick. func encodeFilterToJSON(query, viewType string) ([]byte, error) { q, err := url.ParseQuery(strings.TrimPrefix(query, "?")) if err != nil { return nil, err } f := ParseTreeFilter(q) payload := map[string]any{ "view_type": viewType, } if f.Q != "" { payload["q"] = f.Q } if len(f.Tags) > 0 { payload["tags"] = f.Tags } if len(f.Management) > 0 { payload["management"] = f.Management } if !(len(f.Status) == 1 && f.Status[0] == "active") { payload["status"] = f.Status } if len(f.HasLinks) > 0 { payload["has_links"] = f.HasLinks } if f.Public != nil { payload["public"] = *f.Public } if f.ShowArchived { payload["show_archived"] = true } if f.ProjectPath != "" { payload["project_path"] = f.ProjectPath if !f.IncludeDescendants { payload["include_descendants"] = false } } return json.Marshal(payload) } // decodeViewSpec parses filter_json into a TreeFilter + view_type + // group_by. Inverse of encodeFilterToJSON. func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) { f := TreeFilter{ Status: []string{"active"}, IncludeDescendants: true, } viewType := "" groupBy := "" if len(filterJSON) == 0 { return f, viewType, groupBy } payload := map[string]any{} if err := json.Unmarshal(filterJSON, &payload); err != nil { return f, viewType, groupBy } if v, ok := payload["view_type"].(string); ok { viewType = v } if v, ok := payload["group_by"].(string); ok { groupBy = v } if v, ok := payload["q"].(string); ok { f.Q = v } if v, ok := payload["tags"].([]any); ok { f.Tags = anySliceToStrings(v) } if v, ok := payload["management"].([]any); ok { f.Management = anySliceToStrings(v) } if v, ok := payload["status"].([]any); ok { f.Status = anySliceToStrings(v) if len(f.Status) == 0 { f.Status = []string{"active"} } } if v, ok := payload["has_links"].([]any); ok { f.HasLinks = anySliceToStrings(v) } if v, ok := payload["public"].(bool); ok { f.Public = &v } if v, ok := payload["show_archived"].(bool); ok && v { f.ShowArchived = true } if v, ok := payload["project_path"].(string); ok { f.ProjectPath = v } if v, ok := payload["include_descendants"].(bool); ok { f.IncludeDescendants = v } return f, viewType, groupBy } // overlayURLOntoSavedFilter applies URL-query chip values on top of the // saved-view baseline. Same pattern the 5i fix-shift had (URL overrides // saved); slice B reintroduces it here on the /views/{slug} render path. func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) { if q.Get("q") != "" { base.Q = urlFilter.Q } if _, ok := q["tag"]; ok { base.Tags = urlFilter.Tags } if _, ok := q["mgmt"]; ok { base.Management = urlFilter.Management } if _, ok := q["status"]; ok { base.Status = urlFilter.Status } if _, ok := q["has"]; ok { base.HasLinks = urlFilter.HasLinks } if q.Get("show-archived") != "" { base.ShowArchived = urlFilter.ShowArchived } if q.Get("public") != "" { base.Public = urlFilter.Public } if q.Get("project") != "" { base.ProjectPath = urlFilter.ProjectPath } if q.Get("project_descendants") != "" { base.IncludeDescendants = urlFilter.IncludeDescendants } } func anySliceToStrings(in []any) []string { out := make([]string, 0, len(in)) for _, v := range in { if s, ok := v.(string); ok { out = append(out, s) } } return out }