Merge branch 'mai/knuth/phase-3g-stale'

This commit is contained in:
mAi
2026-05-15 19:13:47 +02:00
7 changed files with 546 additions and 9 deletions

View File

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

40
gitea/repo.go Normal file
View File

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

51
gitea/repo_test.go Normal file
View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -32,13 +32,21 @@
{{if .Filter.Active}}<a class="clear" href="/dashboard">clear filters</a>{{end}}
</form>
<p class="counts muted">
{{if .P.Cached}}<small title="Served from 60s in-memory cache">cached</small>
{{else}}<small>fresh — built {{.P.BuiltAt.Format "15:04:05"}}</small>{{end}}
{{if .P.Cached}}<small title="Served from 60s in-memory cache · built {{.P.BuiltAt.Format "15:04:05"}}">updated {{.UpdatedRel}} · cached</small>
{{else}}<small>updated {{.UpdatedRel}} · fresh</small>{{end}}
<a class="refresh" href="{{.RefreshURL}}"
hx-get="{{.RefreshURL}}"
hx-target="#dashboard-section"
hx-swap="outerHTML"
title="force-refresh: bust the 60s cache for this filter">↻ refresh</a>
</p>
</section>
<section class="dash-grid">
{{$collapse := not .FilterActive}}
{{if or .P.Tasks (not $collapse)}}
<article class="card card-tasks">
<header>
<h2>Open tasks <small class="muted">({{.P.TaskTotal}})</small></h2>
@@ -74,7 +82,11 @@
<p class="empty muted">Nothing open. Nice.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No open tasks.</p>
{{end}}
{{if or .P.Issues (not $collapse)}}
<article class="card card-issues">
<header>
<h2>Open issues <small class="muted">({{.P.IssueTotal}})</small></h2>
@@ -95,7 +107,11 @@
<p class="empty muted">No open issues across linked repos.</p>
{{end}}
</article>
{{else}}
<p class="card-collapsed muted">No open issues.</p>
{{end}}
{{if or .P.RecentDocs (not $collapse)}}
<article class="card card-docs">
<header>
<h2>Recent documents <small class="muted">({{.P.RecentDocsTotal}}, last 30d)</small></h2>
@@ -116,6 +132,26 @@
<p class="empty muted">Nothing dated in the last 30 days.</p>
{{end}}
</article>
{{else}}
<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}}
</section>
</section>