feat(phase 3g dashboard polish): stale-projects card + refresh button + empty-collapse
- gitea.GetRepo returns FullName + UpdatedAt for the stale-card probe - dashboard collectStale: mai-managed items + linked-repo updated_at >60d + zero open tasks + zero open issues. Sorted longest-stale first, ≤20. Multi-repo items need ALL repos quiet to count as stale. Reuses the 4-worker pool + the already-aggregated task/issue counts from the Tasks / Issues cards (no extra DAV/Gitea fetches). - dashboardCache.invalidate(key) busts a single filter's cache entry; ?refresh=1 routes through it so ↻ button gets fresh data. - "updated Nm ago · cached/fresh" label + ↻ refresh link in dashboard chrome. - Empty-card collapse: with no filter + zero rows the card renders as a one-line muted note instead of full chrome. Filter-active cards keep chrome so m can tell "filter hid it" from "nothing there". - design.md §"Dashboard / daily-driver view" extended with the 4 new surfaces; the 3e "stale (3f)" out-of-scope line dropped. - 5 new tests: stale-surface, stale-skip-recent, refresh-busts-cache, empty-collapse, filter-keeps-chrome. 2 unit tests for gitea.GetRepo.
This commit is contained in:
202
web/dashboard.go
202
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.
|
||||
|
||||
Reference in New Issue
Block a user