From 87132ee166c8c512d82a1c35c970464bb79b38ba Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 12:27:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20scope=20chip=20+=20Quiet=20(?= =?UTF-8?q?N)=20=E2=96=BE=20fold=20+=20Stale=20folded=20into=20Tiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5h slice 3 — splits the Tiles rollup into ProjectsCurrent (primary grid) and ProjectsQuiet (collapsible fold) per m's §7 'pinned ∪ recently-active ∪ open-work' rule. URL contract extended: /dashboard — Tiles, scope=current (defaults elided) /dashboard?scope=all — every active project in the grid /dashboard?scope=current — same as default (chip allows explicit) Scope chip lives next to the tab strip on Tiles only; Tasks + Events tabs hide it (no scope concept there). Default chip label: '◇ current', flips to '○ all' when scope=all. Chip href toggles to the alternate state preserving filter + view. Quiet fold: -
element opened on click — projects with IsCurrent=false land here, including all stale candidates. - Fold summary: 'Quiet (N) — older than 14d · M stale' (M omitted when 0). - Quiet tiles render with the same shape as primary tiles, slightly faded; stale tiles also carry a 'tile-stale' class (dashed border) and a 'stale' flag in the header. Stale card on the Tasks tab retires entirely — m's pick. The LastActivity stamp on each tile carries the staleness signal; the 'consider archiving?' nudge migrates to the Quiet fold framing. Stale data still computes (collectStale runs in buildDashboard) because the rollup needs the per-item stale flag and the repo-activity map for LastActivity. Cache key extends: (filter | view=X | scope=Y) so toggling scope from the chip lands in a separate cache slot (no stale render). Tests: - TestDashboardStaleCardSurfacesDormantMaiProject retargeted at the new Quiet fold + tile-stale class on Tiles. - TestDashboardStaleCardSkipsRecentRepo asserts the inverse via class inspection on the tile
. - 4 new tests cover the scope chip: renders on Tiles only, label flips on scope=all, scope=all hides the Quiet fold, chip URL flips correctly. Empty state: scope=current with no current projects shows a 'Nothing current. Pin a project, or show all active.' note with a direct link to scope=all. --- web/dashboard.go | 116 ++++++++++++++++++++++++--- web/dashboard_test.go | 44 ++++++---- web/dashboard_view_test.go | 68 ++++++++++++++++ web/static/style.css | 24 ++++++ web/templates/dashboard_section.tmpl | 29 +++---- web/templates/dashboard_tiles.tmpl | 103 +++++++++++++++--------- 6 files changed, 308 insertions(+), 76 deletions(-) diff --git a/web/dashboard.go b/web/dashboard.go index 038694c..44da631 100644 --- a/web/dashboard.go +++ b/web/dashboard.go @@ -47,6 +47,15 @@ type dashboardPayload struct { // primary path ascending. Projects []dashboardProject + // ProjectsCurrent / ProjectsQuiet are the per-request scope-split of + // Projects — populated by the handler, not the cache, so the scope + // chip can toggle without recomputing. The Tiles view renders Current + // in the primary grid and Quiet behind a "Quiet (N) ▾" fold. + ProjectsCurrent []dashboardProject `json:"-"` + ProjectsQuiet []dashboardProject `json:"-"` + QuietStaleCount int `json:"-"` // subset of ProjectsQuiet flagged stale, for the fold label + QuietWindowLabel string `json:"-"` // e.g. "14d" — derived from dashboardActivityWindow + BuiltAt time.Time Cached bool } @@ -123,6 +132,14 @@ const ( dashboardViewEvents = "events" ) +// Dashboard scope (Phase 5h Tiles view) — narrows the tile grid to the +// projects m is plausibly working on (the IsCurrent set), folding the +// rest into a "Quiet (N) ▾" section. Default = current. +const ( + dashboardScopeCurrent = "current" + dashboardScopeAll = "all" +) + // parseDashboardView normalizes the ?view= query into one of the three // known shapes, falling back to Tiles (the default per m's §7 pick). func parseDashboardView(raw string) string { @@ -136,21 +153,31 @@ func parseDashboardView(raw string) string { } } +// parseDashboardScope normalizes the ?scope= query, falling back to +// "current" (the daily-driver default per m's §7 pick). +func parseDashboardScope(raw string) string { + if strings.EqualFold(strings.TrimSpace(raw), dashboardScopeAll) { + return dashboardScopeAll + } + return dashboardScopeCurrent +} + // handleDashboard renders the cross-project landing page. Filters reuse the // tree-page TreeFilter; the per-card aggregation runs sequentially with a // small worker pool to avoid hammering DAV / Gitea. func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { filter := ParseTreeFilter(r.URL.Query()) view := parseDashboardView(r.URL.Query().Get("view")) + scope := parseDashboardScope(r.URL.Query().Get("scope")) // Dashboard treats status=active as the meaningful default — same as tree. filterKey := filter.QueryString() if filterKey == "" { filterKey = "__empty__" } - // Cache key composes filter + view so each tab has its own 60s TTL - // entry — the underlying data is shared, but the rendered template - // differs and caching the render saves the template work. - cacheKey := filterKey + "|view=" + view + // Cache key composes filter + view + scope so each surface has its own + // 60s TTL entry. Scope only changes the Tiles split; we still key it + // in for every view so the future Activity tab can ride the same path. + cacheKey := filterKey + "|view=" + view + "|scope=" + scope // ?refresh=1 busts this filter's cache entry so the next aggregation // runs fresh — used by the ↻ button on the dashboard chrome. @@ -173,7 +200,23 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { // Updated-relative label: how long since the cached payload was built. updatedRel := relativeTime(time.Now(), payload.BuiltAt) - // Refresh URL preserves the active view + filter. + // Split the rollup into Current vs. Quiet according to the active + // scope. For scope=all we keep everything Current; for scope=current + // (default) we use IsCurrent + the Stale set to populate Quiet. + now := time.Now() + current, quiet := splitProjectsByScope(displayPayload.Projects, scope, now) + displayPayload.ProjectsCurrent = current + displayPayload.ProjectsQuiet = quiet + staleCount := 0 + for _, p := range quiet { + if p.Stale { + staleCount++ + } + } + displayPayload.QuietStaleCount = staleCount + displayPayload.QuietWindowLabel = strconv.Itoa(int(dashboardActivityWindow/(24*time.Hour))) + "d" + + // Refresh URL preserves the active view + scope + filter. refreshQuery := filterKey if refreshQuery == "__empty__" { refreshQuery = "" @@ -184,6 +227,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { } refreshQuery += "view=" + view } + if scope != dashboardScopeCurrent { + if refreshQuery != "" { + refreshQuery += "&" + } + refreshQuery += "scope=" + scope + } refreshURL := "/dashboard?" if refreshQuery != "" { refreshURL += refreshQuery + "&" @@ -195,7 +244,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { "P": displayPayload, "Filter": filter, "View": view, - "Tabs": dashboardTabs(view, filterKey), + "Scope": scope, + "Tabs": dashboardTabs(view, filterKey, scope), + "ScopeURL": dashboardScopeToggleURL(view, scope, filterKey), "UpdatedRel": updatedRel, "RefreshURL": refreshURL, "FilterActive": filter.Active(), @@ -207,6 +258,49 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { s.render(w, r, "dashboard", data) } +// splitProjectsByScope partitions the rollup into the primary grid +// (ProjectsCurrent) and the Quiet fold (ProjectsQuiet) per m's §7 pick. +// scope=all → everything counts as Current; Quiet is empty. +// scope=current → IsCurrent(now) selects Current; the rest (including +// stale candidates) move to Quiet. +func splitProjectsByScope(projects []dashboardProject, scope string, now time.Time) (current, quiet []dashboardProject) { + if scope == dashboardScopeAll { + return projects, nil + } + for _, p := range projects { + if p.IsCurrent(now) { + current = append(current, p) + } else { + quiet = append(quiet, p) + } + } + return current, quiet +} + +// dashboardScopeToggleURL builds the URL that flips the scope chip — +// /dashboard with the alternate scope and the current view + filter +// preserved. +func dashboardScopeToggleURL(view, scope, filterKey string) string { + next := dashboardScopeAll + if scope == dashboardScopeAll { + next = dashboardScopeCurrent + } + parts := []string{} + if filterKey != "__empty__" && filterKey != "" { + parts = append(parts, filterKey) + } + if view != dashboardViewTiles { + parts = append(parts, "view="+view) + } + if next != dashboardScopeCurrent { + parts = append(parts, "scope="+next) + } + if len(parts) == 0 { + return "/dashboard" + } + return "/dashboard?" + strings.Join(parts, "&") +} + // dashboardTab is a single entry in the view-switcher strip. type dashboardTab struct { View string // tiles | tasks | events @@ -216,9 +310,10 @@ type dashboardTab struct { } // dashboardTabs builds the three-entry tab strip with each tab's URL -// preserving the active filter. The default view (tiles) elides from -// the URL so the address bar stays clean on the daily-driver path. -func dashboardTabs(active, filterKey string) []dashboardTab { +// preserving the active filter + scope. The default view (tiles) and +// scope (current) elide from the URL so the address bar stays clean +// on the daily-driver path. +func dashboardTabs(active, filterKey, scope string) []dashboardTab { prefix := "/dashboard" filterQuery := "" if filterKey != "__empty__" && filterKey != "" { @@ -232,6 +327,9 @@ func dashboardTabs(active, filterKey string) []dashboardTab { if view != dashboardViewTiles { parts = append(parts, "view="+view) } + if scope != dashboardScopeCurrent { + parts = append(parts, "scope="+scope) + } if len(parts) == 0 { return prefix } diff --git a/web/dashboard_test.go b/web/dashboard_test.go index e0f4a5f..668cf5c 100644 --- a/web/dashboard_test.go +++ b/web/dashboard_test.go @@ -265,17 +265,20 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) { } h := srv.Routes() - // The Stale card lives on the Tasks tab (Phase 5h folds it under - // Quiet on Tiles — that's a separate slice). - code, body := get(t, h, "/dashboard?view=tasks") + // Phase 5h: the Stale card retired. The stale project now appears + // inside the Tiles Quiet fold with a tile-stale flag on the tile. + code, body := get(t, h, "/dashboard") if code != 200 { - t.Fatalf("GET /dashboard?view=tasks → %d", code) + t.Fatalf("GET /dashboard → %d", code) } - if !strings.Contains(body, "card-stale") { - t.Fatalf("expected stale card to render — body lacks 'card-stale'") + if !strings.Contains(body, `class="dash-quiet"`) { + t.Fatalf("expected dash-quiet fold to render — body lacks 'class=\"dash-quiet\"'") + } + if !strings.Contains(body, `tile tile-stale`) { + t.Errorf("expected stale tile with 'tile tile-stale' class — body missing it") } if !strings.Contains(body, "/i/dev."+slug) { - t.Errorf("expected stale list to include /i/dev.%s", slug) + t.Errorf("expected stale tile to link to /i/dev.%s", slug) } } @@ -325,13 +328,26 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) { } h := srv.Routes() - // Match the inverse check against the Tasks tab where Stale lives. - _, body := get(t, h, "/dashboard?view=tasks") - // A recent repo creates a tile (under Tiles view) AND a /i/ link on - // the Stale card-collapsed area would be unexpected. The Tasks tab's - // Stale card is what this guards. - if strings.Contains(body, `class="stale-row"`) && strings.Contains(body, "/i/dev."+slug) { - t.Errorf("recent repo should NOT surface in stale card — body contains stale-row with /i/dev.%s", slug) + // Phase 5h: assert the tile for this slug is NOT flagged stale. + // Recent repo activity (3d old) puts it solidly inside the activity + // window AND fails the staleness probe, so no tile-stale class. + _, body := get(t, h, "/dashboard") + // Find the tile for this slug and check its class attribute. + marker := `data-item-path="dev.` + slug + `"` + idx := strings.Index(body, marker) + if idx < 0 { + // Tile may not render at all if the project has no signal at + // all; that's also fine — the negative assertion holds vacuously. + return + } + // Walk back to the start of the
summary.dash-quiet-summary { + cursor: pointer; padding: 6px 0; + border-top: 1px dotted var(--border); + font-size: 0.9em; list-style: revert; +} +.dashboard .dash-quiet[open] > summary.dash-quiet-summary { margin-bottom: 8px; } +.dashboard .dash-tiles-quiet .tile { opacity: 0.85; } .dashboard .dash-events-view { margin-top: 8px; } .dashboard .dash-events-view .event-day-large { margin: 16px 0; } diff --git a/web/templates/dashboard_section.tmpl b/web/templates/dashboard_section.tmpl index 790e200..60d0dcc 100644 --- a/web/templates/dashboard_section.tmpl +++ b/web/templates/dashboard_section.tmpl @@ -51,6 +51,16 @@ hx-swap="outerHTML" hx-push-url="true">{{.Label}} {{end}} + {{if eq .View "tiles"}} + + {{if eq .Scope "current"}}◇ current{{else}}○ all{{end}} + + {{end}} {{if eq .View "tasks"}} @@ -216,22 +226,9 @@

No recent documents.

{{end}} - {{if .P.Stale}} -
-
-

Stale projects ({{.P.StaleTotal}}) · consider archiving?

-
-
    - {{range .P.Stale}} -
  • - {{.Item.PrimaryPath}} - {{.Repo}} - last active {{.StaleRel}} ago -
  • - {{end}} -
-
- {{end}} + {{/* Phase 5h: Stale card retired on the Tasks tab — m's pick was + to fold stale into the Quiet (N) ▾ section under Tiles. The + LastActivity stamp on each tile carries the staleness signal. */}} {{end}} diff --git a/web/templates/dashboard_tiles.tmpl b/web/templates/dashboard_tiles.tmpl index 084baa9..f1d283c 100644 --- a/web/templates/dashboard_tiles.tmpl +++ b/web/templates/dashboard_tiles.tmpl @@ -1,40 +1,69 @@ {{define "dashboard-tiles"}} -
- {{if .P.Projects}} - {{range .P.Projects}} - {{$path := .Item.PrimaryPath}} -
-
- - {{if .Item.Pinned}}{{end}} - {{.Item.Title}} - - {{$path}} - {{if .IsLive}}live{{end}} -
-

- {{if .OpenTasks}}{{.OpenTasks}} open{{end}} - {{if .Overdue}}{{.Overdue}}!{{end}} - {{if .OpenIssues}}{{.OpenIssues}} issue{{if ne .OpenIssues 1}}s{{end}}{{end}} - {{if and (not .OpenTasks) (not .OpenIssues)}}quiet{{end}} -

- {{if .NextSignal}} -

- {{if eq .NextSignalKind "task"}}•{{else}}◆{{end}} - {{.NextSignal}} -

- {{end}} -
- {{if .LastActivityRel}} - {{.LastActivityRel}} - {{else}} - - {{end}} -
-
+ {{if .P.ProjectsCurrent}} +
+ {{range .P.ProjectsCurrent}} + {{template "tile" .}} {{end}} - {{else}} -

No projects to show.

- {{end}} -
+
+ {{else}} +

+ {{if eq .Scope "current"}} + Nothing current. Pin a project from its detail page, or + show all active. + {{else}} + No projects to show. + {{end}} +

+ {{end}} + + {{if .P.ProjectsQuiet}} +
+ + Quiet ({{len .P.ProjectsQuiet}}) — older than {{.P.QuietWindowLabel}}{{if .P.QuietStaleCount}} · {{.P.QuietStaleCount}} stale{{end}} + +
+ {{range .P.ProjectsQuiet}} + {{template "tile" .}} + {{end}} +
+
+ {{end}} +{{end}} + +{{define "tile"}} + {{$path := .Item.PrimaryPath}} +
+
+ + {{if .Item.Pinned}}{{end}} + {{.Item.Title}} + + {{$path}} + {{if .IsLive}}live{{end}} + {{if .Stale}}stale{{end}} +
+

+ {{if .OpenTasks}}{{.OpenTasks}} open{{end}} + {{if .Overdue}}{{.Overdue}}!{{end}} + {{if .OpenIssues}}{{.OpenIssues}} issue{{if ne .OpenIssues 1}}s{{end}}{{end}} + {{if and (not .OpenTasks) (not .OpenIssues)}}quiet{{end}} +

+ {{if .NextSignal}} +

+ {{if eq .NextSignalKind "task"}}•{{else}}◆{{end}} + {{.NextSignal}} +

+ {{end}} + +
{{end}}