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:
@@ -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
40
gitea/repo.go
Normal 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
51
gitea/repo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user