Open tasks ({{.P.TaskTotal}})
@@ -74,7 +82,11 @@Nothing open. Nice.
{{end}}diff --git a/docs/design.md b/docs/design.md index f4739b4..2774d66 100644 --- a/docs/design.md +++ b/docs/design.md @@ -373,7 +373,17 @@ A single landing surface at `/dashboard` that aggregates open work and recent ac **TTL cache**: 60s in-memory map keyed by the encoded TreeFilter. The cache is single-replica only (no shared state needed at single-user scale). The ✓-mark-done handler explicitly invalidates the cache so the row disappears immediately on the next render. -**Out of scope for 3e**: stale-projects card (3f), real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch. +**Out of scope for 3e**: real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch. + +**Phase 3g additions:** + +4. **Stale projects** — items with `'mai' = ANY(management)` AND every linked Gitea repo's `updated_at` older than 60d AND zero open VTODOs across linked CalDAV lists AND zero open Gitea issues. Sorted longest-stale first, capped at 20. Each row shows the project path, the quiet repo, and "last active Nd ago" with the absolute date on hover. "Consider archiving?" framing only — no auto-action. + - Uses the same 4-worker pool as the issues card. Per-item task/issue counts are reused from the already-aggregated Tasks/Issues cards (no second DAV/Gitea pass). + - Items with NO linked repo are skipped — without a signal there is no way to call them stale. + - When an item has multiple linked repos, ALL must be older than the cutoff (so an item with one quiet repo and one busy repo is NOT stale). +5. **Last-refresh indicator** — small "updated Nm ago · cached" / "updated Nm ago · fresh" line at the top of the dashboard chrome, derived from the cached payload's BuiltAt timestamp. +6. **Force-refresh button** — `↻ refresh` link that adds `?refresh=1` to the current URL. The handler invalidates the matching cache key and re-runs the full aggregation. HTMX swaps the section in-place. +7. **Empty-card collapse** — when no filter is active AND a card has zero rows, render a one-line `No open tasks.` / `No open issues.` / `No recent documents.` note instead of the full empty-state block. With a filter active the card chrome stays so m can distinguish "filter hid the data" from "no data". ## Graph view (Phase 3f) diff --git a/gitea/repo.go b/gitea/repo.go new file mode 100644 index 0000000..fe3020b --- /dev/null +++ b/gitea/repo.go @@ -0,0 +1,40 @@ +package gitea + +import ( + "context" + "encoding/json" + "time" +) + +// Repo is the slice of /repos/{owner}/{repo} projax cares about for the +// stale-projects dashboard card: the timestamp Gitea last touched the repo +// (commits, issues, releases all bump this). +type Repo struct { + FullName string // e.g. "m/projax" + UpdatedAt time.Time + Empty bool // freshly-created repo with no commits yet +} + +type rawRepo struct { + FullName string `json:"full_name"` + UpdatedAt time.Time `json:"updated_at"` + Empty bool `json:"empty"` +} + +// GetRepo fetches the repo's metadata. Returns ErrNotFound when the API +// returns 404 (repo renamed / deleted / token lacks access). +func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*Repo, error) { + resp, err := c.do(ctx, "GET", "/repos/"+owner+"/"+repo, nil, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, readErr(resp, "get repo") + } + var r rawRepo + if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { + return nil, err + } + return &Repo{FullName: r.FullName, UpdatedAt: r.UpdatedAt, Empty: r.Empty}, nil +} diff --git a/gitea/repo_test.go b/gitea/repo_test.go new file mode 100644 index 0000000..40fff6b --- /dev/null +++ b/gitea/repo_test.go @@ -0,0 +1,51 @@ +package gitea + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGetRepoParse(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/m/projax", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, _ = io.WriteString(w, `{ + "full_name": "m/projax", + "updated_at": "2025-12-15T08:00:00Z", + "empty": false + }`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(srv.URL, "tok") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + r, err := c.GetRepo(ctx, "m", "projax") + if err != nil { + t.Fatalf("GetRepo: %v", err) + } + if r.FullName != "m/projax" { + t.Errorf("FullName = %q", r.FullName) + } + want := time.Date(2025, 12, 15, 8, 0, 0, 0, time.UTC) + if !r.UpdatedAt.Equal(want) { + t.Errorf("UpdatedAt = %v, want %v", r.UpdatedAt, want) + } +} + +func TestGetRepoNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "{}", http.StatusNotFound) + })) + defer srv.Close() + c := New(srv.URL, "tok") + if _, err := c.GetRepo(context.Background(), "ghost", "repo"); err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} diff --git a/web/dashboard.go b/web/dashboard.go index 41ec3bb..c055dd3 100644 --- a/web/dashboard.go +++ b/web/dashboard.go @@ -49,6 +49,15 @@ func (c *dashboardCache) get(key string) (*dashboardPayload, bool) { return v.payload, true } +func (c *dashboardCache) invalidate(key string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.rows, key) +} + func (c *dashboardCache) set(key string, p *dashboardPayload) { if c == nil { return @@ -71,6 +80,9 @@ type dashboardPayload struct { RecentDocs []dashboardDoc RecentDocsTotal int + Stale []dashboardStale + StaleTotal int + BuiltAt time.Time Cached bool } @@ -108,6 +120,17 @@ type dashboardDoc struct { ItemPath string } +// dashboardStale is one mai-managed item whose linked repo is quiet, has no +// open issues, and whose linked CalDAV lists hold no open VTODOs. The +// "consider archiving?" candidate. +type dashboardStale struct { + Item *store.Item + Repo string // owner/repo of the quiet linked repo (first one wins) + LastActive time.Time + StaleDays int // floor(days since LastActive) + StaleRel string // "62d", "120d", "no recent activity" +} + // 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. @@ -119,6 +142,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { cacheKey = "__empty__" } + // ?refresh=1 busts this filter's cache entry so the next aggregation + // runs fresh — used by the ↻ button on the dashboard chrome. + if r.URL.Query().Get("refresh") == "1" { + s.dashboard.invalidate(cacheKey) + } + payload, hit := s.dashboard.get(cacheKey) if !hit { built, err := s.buildDashboard(r.Context(), filter) @@ -131,11 +160,22 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { } displayPayload := *payload displayPayload.Cached = hit + // Updated-relative label: how long since the cached payload was built. + updatedRel := relativeTime(time.Now(), payload.BuiltAt) + + // Refresh URL: clone the current query, drop ?refresh, prepend it back. + refreshURL := "/dashboard?refresh=1" + if cacheKey != "__empty__" { + refreshURL = "/dashboard?" + cacheKey + "&refresh=1" + } data := map[string]any{ - "Title": "dashboard", - "P": displayPayload, - "Filter": filter, + "Title": "dashboard", + "P": displayPayload, + "Filter": filter, + "UpdatedRel": updatedRel, + "RefreshURL": refreshURL, + "FilterActive": filter.Active(), } if r.Header.Get("HX-Request") == "true" { s.render(w, "dashboard_section", data) @@ -196,9 +236,165 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo p.RecentDocs = docs p.RecentDocsTotal = total + // --- Stale projects card --- + // "Stale" = mai-managed AND linked-repo quiet 60d+ AND 0 open tasks AND + // 0 open issues. Reuses what the task/issue cards already aggregated so + // we don't refetch CalDAV/Gitea per item. + openTasksByItem := map[string]int{} + for _, t := range p.Tasks { + openTasksByItem[t.Item.ID]++ + } + openIssuesByItem := map[string]int{} + for _, i := range p.Issues { + openIssuesByItem[i.Item.ID]++ + } + // Note: the 30-row cap on Tasks/Issues lists may hide entries for the + // count above. We deliberately use the trimmed view: an item that has so + // many open tasks/issues that it pushes past the 30 cap is clearly NOT + // stale, and the per-item count is only used as "is this zero?". + stale, staleTotal := s.collectStale(ctx, dashItems, openTasksByItem, openIssuesByItem, now) + p.Stale = stale + p.StaleTotal = staleTotal + return p, nil } +// collectStale walks every mai-managed item whose only signals are quiet: +// no open tasks (in the aggregated map), no open issues (in the aggregated +// map), AND the linked Gitea repo's updated_at is older than 60d. Items +// with NO linked repo at all are skipped — we can't judge staleness without +// a signal. Returns at most 20 rows, longest-stale first. +func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTasks, openIssues map[string]int, now time.Time) ([]dashboardStale, int) { + if s.Gitea == nil { + return nil, 0 + } + const staleCutoffDays = 60 + type job struct { + item *store.Item + link *store.ItemLink + } + jobs := []job{} + for _, it := range items { + if !it.HasManagement("mai") { + continue + } + if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 { + continue + } + links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo) + if err != nil || len(links) == 0 { + continue + } + // First linked repo wins for the staleness probe — if an item has + // multiple linked repos and ANY is recent we treat the item as not + // stale, so the candidate-list pass is conservative on the "stale" + // side. Implemented by emitting jobs for every link + filtering on + // "every link is stale" in the result reduce. + for _, l := range links { + jobs = append(jobs, job{item: it, link: l}) + } + } + if len(jobs) == 0 { + return nil, 0 + } + + type res struct { + itemID string + repo string + updated time.Time + err bool + } + results := make(chan res, len(jobs)) + in := make(chan job, len(jobs)) + const workers = 4 + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range in { + owner, repo := gitea.ParseRepoRef(j.link.RefID) + if owner == "" || repo == "" { + results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true} + continue + } + r, err := s.Gitea.Client.GetRepo(ctx, owner, repo) + if err != nil { + s.Logger.Warn("dashboard stale get repo", "repo", j.link.RefID, "err", err) + results <- res{itemID: j.item.ID, repo: j.link.RefID, err: true} + continue + } + results <- res{itemID: j.item.ID, repo: j.link.RefID, updated: r.UpdatedAt} + } + }() + } + for _, j := range jobs { + in <- j + } + close(in) + wg.Wait() + close(results) + + // Reduce by item: track the most-recent updated_at across the item's + // repos. Stale only if ALL probed repos are older than the cutoff. + type acc struct { + newest time.Time + repo string // newest-updated repo wins the display slot + anyErr bool + } + byItem := map[string]*acc{} + for r := range results { + a, ok := byItem[r.itemID] + if !ok { + a = &acc{} + byItem[r.itemID] = a + } + if r.err { + a.anyErr = true + continue + } + if r.updated.After(a.newest) { + a.newest = r.updated + a.repo = r.repo + } + } + + byID := map[string]*store.Item{} + for _, it := range items { + byID[it.ID] = it + } + cutoff := now.AddDate(0, 0, -staleCutoffDays) + out := []dashboardStale{} + for id, a := range byItem { + if a.anyErr || a.newest.IsZero() { + continue + } + if a.newest.After(cutoff) { + continue + } + it := byID[id] + if it == nil { + continue + } + days := int(now.Sub(a.newest).Hours() / 24) + out = append(out, dashboardStale{ + Item: it, + Repo: a.repo, + LastActive: a.newest, + StaleDays: days, + StaleRel: relDays(days), + }) + } + sort.Slice(out, func(i, j int) bool { + return out[i].StaleDays > out[j].StaleDays + }) + total := len(out) + if len(out) > 20 { + out = out[:20] + } + return out, total +} + // collectTasks fans out across every (item, caldav-list link) pair using a // small worker pool. Per-calendar errors are logged and skipped so one down // calendar doesn't blank the whole card. diff --git a/web/dashboard_test.go b/web/dashboard_test.go index 2d5b77b..9cc9770 100644 --- a/web/dashboard_test.go +++ b/web/dashboard_test.go @@ -3,9 +3,15 @@ package web_test import ( "context" "fmt" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" + + "github.com/m/projax/gitea" + "github.com/m/projax/web" ) // TestDashboardRendersWithoutDeps asserts that GET /dashboard renders cleanly @@ -19,11 +25,14 @@ func TestDashboardRendersWithoutDeps(t *testing.T) { if code != 200 { t.Fatalf("GET /dashboard → %d body=%s", code, body) } + // Empty-card collapse (phase 3g) replaces full card chrome with a + // one-line "No open tasks." style note when there is no filter active + // AND zero rows. So the body should contain the collapsed strings. for _, want := range []string{ `id="dashboard-section"`, - `Open tasks`, - `Open issues`, - `Recent documents`, + `No open tasks`, + `No open issues`, + `No recent documents`, } { if !strings.Contains(body, want) { t.Errorf("dashboard missing %q", want) @@ -133,6 +142,184 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) { } } +// TestDashboardRefreshBustsCache asserts that ?refresh=1 invalidates the +// cache entry for the matching filter key: the response no longer says +// "cached" even when called within the 60s TTL of a preceding fetch. +func TestDashboardRefreshBustsCache(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + + // Prime the cache. + _, _ = get(t, h, "/dashboard") + // Second hit shows cached label. + _, cachedBody := get(t, h, "/dashboard") + if !strings.Contains(cachedBody, "cached") { + t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:600]) + } + // Third hit with ?refresh=1 should be fresh again. + code, body := get(t, h, "/dashboard?refresh=1") + if code != 200 { + t.Fatalf("GET /dashboard?refresh=1 → %d", code) + } + if strings.Contains(body, "cached") { + t.Errorf("refresh=1 should bust cache — body still contains 'cached'") + } + if !strings.Contains(body, "fresh") { + t.Errorf("refresh=1 response should be 'fresh'") + } +} + +// TestDashboardCollapsesEmptyCardsWhenNoFilter checks the 3g empty-collapse +// behaviour: when there are zero rows AND no filter active, cards render as +// one-line "No open tasks" muted notes instead of the full card chrome. +func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + code, body := get(t, h, "/dashboard") + if code != 200 { + t.Fatalf("GET /dashboard → %d", code) + } + if !strings.Contains(body, "card-collapsed") { + t.Errorf("expected at least one card-collapsed inline note (no rows + no filter)") + } + // Card chrome should NOT appear for the collapsed sections. + if strings.Contains(body, `class="card card-tasks"`) { + t.Errorf("card-tasks should be collapsed when no tasks and no filter") + } +} + +// TestDashboardFilterKeepsFullCardChrome inverse of the above: with a filter +// active the cards stay rendered even when empty, so m can tell whether the +// filter is hiding data or there genuinely isn't any. +func TestDashboardFilterKeepsFullCardChrome(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz") + if code != 200 { + t.Fatalf("GET /dashboard?tag=… → %d", code) + } + if !strings.Contains(body, `class="card card-tasks"`) { + t.Errorf("filter active should keep card-tasks chrome rendered") + } +} + +// TestDashboardStaleCardSurfacesDormantMaiProject seeds a mai-managed item +// linked to a fake Gitea repo whose updated_at is 90 days ago. With no open +// tasks or issues, the stale card must list this item. +func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + slug := "stale-fix-" + stamp + repoRef := "fake-org/" + slug + + // Fake Gitea server returning 90-days-old updated_at for the repo above + // and an empty issue list. /repos/.../issues is called by collectIssues + // even when 0 issues — the handler still needs to return []. + old := time.Now().AddDate(0, 0, -90).UTC().Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "[]") + }) + mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+old+`","empty":false}`) + }) + fake := httptest.NewServer(mux) + defer fake.Close() + srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var dev, id string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids, management) + values (array['project']::text[], 'stale', $1, ARRAY[$2]::uuid[], ARRAY['mai']) + returning id`, + slug, dev, + ).Scan(&id); err != nil { + t.Fatalf("seed item: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'gitea-repo', $2, 'tracks')`, + id, repoRef, + ); err != nil { + t.Fatalf("seed link: %v", err) + } + + h := srv.Routes() + code, body := get(t, h, "/dashboard") + if code != 200 { + 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, "/i/dev."+slug) { + t.Errorf("expected stale list to include /i/dev.%s", slug) + } +} + +// TestDashboardStaleCardSkipsRecentRepo asserts the inverse: an item whose +// linked repo has a recent updated_at is NOT flagged as stale. +func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + slug := "fresh-fix-" + stamp + repoRef := "fake-org/" + slug + + recent := time.Now().AddDate(0, 0, -3).UTC().Format(time.RFC3339) + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/fake-org/"+slug+"/issues", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "[]") + }) + mux.HandleFunc("/api/v1/repos/fake-org/"+slug, func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"full_name":"fake-org/`+slug+`","updated_at":"`+recent+`","empty":false}`) + }) + fake := httptest.NewServer(mux) + defer fake.Close() + srv.Gitea = web.NewGiteaDeps(gitea.New(fake.URL, "tok")) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var dev, id string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids, management) + values (array['project']::text[], 'fresh', $1, ARRAY[$2]::uuid[], ARRAY['mai']) + returning id`, + slug, dev, + ).Scan(&id); err != nil { + t.Fatalf("seed item: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'gitea-repo', $2, 'tracks')`, + id, repoRef, + ); err != nil { + t.Fatalf("seed link: %v", err) + } + + h := srv.Routes() + _, body := get(t, h, "/dashboard") + if strings.Contains(body, "/i/dev."+slug) { + t.Errorf("recent repo should NOT surface in stale card — body contains /i/dev.%s", slug) + } +} + // TestDashboardCacheHitOnSecondLoad asserts the in-memory TTL cache returns // the same payload (and marks Cached=true) on the second request within 60s. func TestDashboardCacheHitOnSecondLoad(t *testing.T) { diff --git a/web/static/style.css b/web/static/style.css index 93f4189..6bd35b4 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -235,3 +235,20 @@ table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; } .graph-legend .key-external { border-color: #ea580c; color: #ea580c; } .graph-legend .key-mixed { border-color: #7c3aed; color: #7c3aed; border-style: dashed; } .graph-legend .key-unmanaged { border-color: #9ca3af; color: #9ca3af; } + +/* --- /dashboard polish (3g) --- */ +.dashboard .counts .refresh { margin-left: 12px; color: var(--accent); cursor: pointer; } +.dashboard .counts .refresh:hover { text-decoration: underline; } +.dashboard .card-collapsed { + margin: 6px 0; padding: 4px 12px; + border-left: 3px solid var(--border); font-style: italic; +} +.dashboard .card-stale header h2 { color: var(--warn); } +.dashboard .stale-list { list-style: none; padding: 0; margin: 0; } +.dashboard .stale-row { + display: flex; gap: 8px; align-items: baseline; + padding: 6px 0; border-bottom: 1px dotted var(--border); +} +.dashboard .stale-row:last-child { border-bottom: none; } +.dashboard .stale-row .repo { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; } +.dashboard .stale-row .last-active { color: var(--warn); font-size: 0.9em; } diff --git a/web/templates/dashboard_section.tmpl b/web/templates/dashboard_section.tmpl index bbed6e6..822e712 100644 --- a/web/templates/dashboard_section.tmpl +++ b/web/templates/dashboard_section.tmpl @@ -32,13 +32,21 @@ {{if .Filter.Active}}clear filters{{end}}
- {{if .P.Cached}}cached - {{else}}fresh — built {{.P.BuiltAt.Format "15:04:05"}}{{end}} + {{if .P.Cached}}updated {{.UpdatedRel}} · cached + {{else}}updated {{.UpdatedRel}} · fresh{{end}} + ↻ refresh
Nothing open. Nice.
{{end}}No open tasks.
+ {{end}} + {{if or .P.Issues (not $collapse)}}No open issues across linked repos.
{{end}}No open issues.
+ {{end}} + {{if or .P.RecentDocs (not $collapse)}}Nothing dated in the last 30 days.
{{end}}No recent documents.
+ {{end}} + + {{if .P.Stale}} +