Merge branch 'mai/fuller/phase-5h-phase-a-design' (phase 5h slice 3: scope chip + Quiet fold + Stale folded into Tiles)

This commit is contained in:
mAi
2026-05-26 12:27:37 +02:00
6 changed files with 308 additions and 76 deletions

View File

@@ -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
}

View File

@@ -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 <article tag and inspect class attr.
start := strings.LastIndex(body[:idx], "<article")
if start < 0 {
t.Fatalf("could not locate article opening for /i/dev.%s", slug)
}
openTag := body[start:idx]
if strings.Contains(openTag, "tile-stale") {
t.Errorf("recent repo (3d old) should NOT be flagged stale — class=%q", openTag)
}
}

View File

@@ -182,3 +182,71 @@ func TestDashboardCacheKeySeparatesViews(t *testing.T) {
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
}
}
// TestDashboardScopeChipRendersOnTilesOnly asserts the scope chip
// (◇ current / ○ all) renders next to the tab strip on Tiles view
// only — Tasks and Events tabs don't have a scope concept.
func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, tiles := get(t, h, "/dashboard")
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
t.Errorf("Tiles view should render the scope chip")
}
if !strings.Contains(tiles, "◇ current") {
t.Errorf("default scope chip should show '◇ current'")
}
_, tasks := get(t, h, "/dashboard?view=tasks")
if strings.Contains(tasks, `class="dash-scope-chip"`) {
t.Errorf("Tasks view should NOT render the scope chip")
}
_, events := get(t, h, "/dashboard?view=events")
if strings.Contains(events, `class="dash-scope-chip"`) {
t.Errorf("Events view should NOT render the scope chip")
}
}
// TestDashboardScopeAllChipFlipsLabel asserts that scope=all renders
// the chip with the alternate label so m can flip back.
func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
if !strings.Contains(body, "○ all") {
t.Errorf("scope=all should render '○ all' chip label")
}
if strings.Contains(body, "◇ current") {
t.Errorf("scope=all should NOT render the '◇ current' label")
}
}
// TestDashboardScopeAllHidesQuietFold asserts that scope=all puts
// everything in the primary grid; no Quiet fold should render because
// nothing is "quiet" under that scope.
func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, body := get(t, h, "/dashboard?scope=all")
if strings.Contains(body, `class="dash-quiet"`) {
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
}
}
// TestDashboardScopeChipURLFlips asserts the chip's href flips between
// ?scope=all and the default /dashboard each toggle.
func TestDashboardScopeChipURLFlips(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
_, defaultBody := get(t, h, "/dashboard")
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
t.Errorf("default scope chip should link to ?scope=all")
}
_, allBody := get(t, h, "/dashboard?scope=all")
if !strings.Contains(allBody, `href="/dashboard"`) {
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
}
}

View File

@@ -331,6 +331,14 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
color: var(--fg); background: var(--surface);
border-color: var(--border);
}
.dashboard .dash-scope-chip {
margin-left: auto; padding: 4px 12px;
font-size: 0.85em; color: var(--muted);
border: 1px solid var(--border); border-radius: 999px;
text-decoration: none; align-self: center;
margin-bottom: 4px;
}
.dashboard .dash-scope-chip:hover { color: var(--fg); border-color: var(--accent); }
.dashboard .dash-tiles {
display: grid; gap: 12px; margin-top: 8px;
@@ -375,6 +383,22 @@ table.bulk .chip-add-btn:hover { background: var(--accent); color: var(--accent-
margin-top: auto; font-size: 0.78em;
display: flex; justify-content: flex-end;
}
.dashboard .tile .tile-stale-flag {
margin-left: auto; font-size: 0.7em;
padding: 1px 6px; border-radius: 999px;
border: 1px dashed var(--warn); color: var(--warn);
}
.dashboard .dash-tiles-empty { margin: 24px 0; }
.dashboard .dash-quiet { margin-top: 18px; }
.dashboard .dash-quiet > 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; }

View File

@@ -51,6 +51,16 @@
hx-swap="outerHTML"
hx-push-url="true">{{.Label}}</a>
{{end}}
{{if eq .View "tiles"}}
<a href="{{.ScopeURL}}" class="dash-scope-chip"
hx-get="{{.ScopeURL}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-push-url="true"
title="Toggle between current projects and the full set">
{{if eq .Scope "current"}}◇ current{{else}}○ all{{end}}
</a>
{{end}}
</nav>
{{if eq .View "tasks"}}
@@ -216,22 +226,9 @@
<p class="card-collapsed muted">No recent documents.</p>
{{end}}
{{if .P.Stale}}
<article class="card card-stale">
<header>
<h2>Stale projects <small class="muted">({{.P.StaleTotal}}) · consider archiving?</small></h2>
</header>
<ul class="stale-list">
{{range .P.Stale}}
<li class="stale-row">
<a class="proj" href="/i/{{.Item.PrimaryPath}}">{{.Item.PrimaryPath}}</a>
<span class="repo muted">{{.Repo}}</span>
<span class="last-active" title="{{.LastActive.Format "2006-01-02"}}">last active {{.StaleRel}} ago</span>
</li>
{{end}}
</ul>
</article>
{{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. */}}
</section>
{{end}}

View File

@@ -1,40 +1,69 @@
{{define "dashboard-tiles"}}
<section class="dash-tiles">
{{if .P.Projects}}
{{range .P.Projects}}
{{$path := .Item.PrimaryPath}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}">
<header class="tile-head">
<a class="tile-title" href="/i/{{$path}}">
{{if .Item.Pinned}}<span class="tile-star" title="pinned">★</span>{{end}}
{{.Item.Title}}
</a>
<span class="tile-path muted">{{$path}}</span>
{{if .IsLive}}<a class="tile-live" href="{{.Item.PublicLiveURL}}" target="_blank" rel="noopener" title="live">live</a>{{end}}
</header>
<p class="tile-counts">
{{if .OpenTasks}}<span class="tile-open"><strong>{{.OpenTasks}}</strong> open</span>{{end}}
{{if .Overdue}}<span class="tile-overdue"><strong>{{.Overdue}}</strong>!</span>{{end}}
{{if .OpenIssues}}<span class="tile-issues"><strong>{{.OpenIssues}}</strong> issue{{if ne .OpenIssues 1}}s{{end}}</span>{{end}}
{{if and (not .OpenTasks) (not .OpenIssues)}}<span class="tile-quiet muted">quiet</span>{{end}}
</p>
{{if .NextSignal}}
<p class="tile-signal" title="{{.NextSignalKind}}">
<span class="tile-signal-kind muted">{{if eq .NextSignalKind "task"}}•{{else}}◆{{end}}</span>
<span class="tile-signal-text">{{.NextSignal}}</span>
</p>
{{end}}
<footer class="tile-foot">
{{if .LastActivityRel}}
<span class="tile-stamp muted" title="{{.LastActivity.Format "2006-01-02 15:04"}}">{{.LastActivityRel}}</span>
{{else}}
<span class="tile-stamp muted">—</span>
{{end}}
</footer>
</article>
{{if .P.ProjectsCurrent}}
<section class="dash-tiles">
{{range .P.ProjectsCurrent}}
{{template "tile" .}}
{{end}}
{{else}}
<p class="empty muted">No projects to show.</p>
{{end}}
</section>
</section>
{{else}}
<p class="empty muted dash-tiles-empty">
{{if eq .Scope "current"}}
Nothing current. Pin a project from its detail page, or
<a href="?scope=all"
hx-get="?scope=all"
hx-target="#dashboard-section"
hx-swap="outerHTML"
hx-push-url="true">show all active</a>.
{{else}}
No projects to show.
{{end}}
</p>
{{end}}
{{if .P.ProjectsQuiet}}
<details class="dash-quiet">
<summary class="dash-quiet-summary muted">
Quiet ({{len .P.ProjectsQuiet}}) — older than {{.P.QuietWindowLabel}}{{if .P.QuietStaleCount}} · {{.P.QuietStaleCount}} stale{{end}}
</summary>
<section class="dash-tiles dash-tiles-quiet">
{{range .P.ProjectsQuiet}}
{{template "tile" .}}
{{end}}
</section>
</details>
{{end}}
{{end}}
{{define "tile"}}
{{$path := .Item.PrimaryPath}}
<article class="tile{{if .Item.Pinned}} tile-pinned{{end}}{{if .Stale}} tile-stale{{end}}" data-item-path="{{$path}}">
<header class="tile-head">
<a class="tile-title" href="/i/{{$path}}">
{{if .Item.Pinned}}<span class="tile-star" title="pinned">★</span>{{end}}
{{.Item.Title}}
</a>
<span class="tile-path muted">{{$path}}</span>
{{if .IsLive}}<a class="tile-live" href="{{.Item.PublicLiveURL}}" target="_blank" rel="noopener" title="live">live</a>{{end}}
{{if .Stale}}<span class="tile-stale-flag muted" title="mai-managed · quiet repo · no open work">stale</span>{{end}}
</header>
<p class="tile-counts">
{{if .OpenTasks}}<span class="tile-open"><strong>{{.OpenTasks}}</strong> open</span>{{end}}
{{if .Overdue}}<span class="tile-overdue"><strong>{{.Overdue}}</strong>!</span>{{end}}
{{if .OpenIssues}}<span class="tile-issues"><strong>{{.OpenIssues}}</strong> issue{{if ne .OpenIssues 1}}s{{end}}</span>{{end}}
{{if and (not .OpenTasks) (not .OpenIssues)}}<span class="tile-quiet muted">quiet</span>{{end}}
</p>
{{if .NextSignal}}
<p class="tile-signal" title="{{.NextSignalKind}}">
<span class="tile-signal-kind muted">{{if eq .NextSignalKind "task"}}•{{else}}◆{{end}}</span>
<span class="tile-signal-text">{{.NextSignal}}</span>
</p>
{{end}}
<footer class="tile-foot">
{{if .LastActivityRel}}
<span class="tile-stamp muted" title="{{.LastActivity.Format "2006-01-02 15:04"}}">{{.LastActivityRel}}</span>
{{else}}
<span class="tile-stamp muted">—</span>
{{end}}
</footer>
</article>
{{end}}