refactor(dashboard): cache via internal/cache.TTLCache

Phase 5b slice B. dashboardCache deleted. The Server's dashboard field
is now `*cache.TTLCache[*dashboardPayload]` constructed via
`cache.NewTTL[*dashboardPayload](dashboardCacheTTL)`. All call sites
renamed:

- s.dashboard.get(k)         → s.dashboard.Get(k)
- s.dashboard.set(k, p)      → s.dashboard.Set(k, p)
- s.dashboard.invalidate(k)  → s.dashboard.Invalidate(k)
- s.dashboard.invalidateAll  → s.dashboard.InvalidateAll
  (across web/dashboard.go, web/server.go, web/caldav.go,
   web/links.go, web/gitea_writeback.go)

The 64-line dashboardCache struct + methods are gone; the dashboard
file shrinks by ~63 lines. TTL constant lifted out to
`dashboardCacheTTL = 60 * time.Second` so the const lives next to its
semantics rather than a magic-number literal in New().

All web/dashboard_*test.go pass unmodified.

Task: t-projax-5b-cache
This commit is contained in:
mAi
2026-05-22 00:25:13 +02:00
parent 599d9a5bb0
commit 085e672dd5
5 changed files with 16 additions and 74 deletions

View File

@@ -390,7 +390,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
// Writeback may move a task on or off the timeline, so bust both caches.
if s.dashboard != nil {
s.dashboard.invalidateAll()
s.dashboard.InvalidateAll()
}
if s.timeline != nil {
s.timeline.invalidateAll()

View File

@@ -15,70 +15,11 @@ import (
"github.com/m/projax/store"
)
// dashboardCache holds the aggregated dashboard payload for up to TTL. Per
// design.md §9 every cache entry is keyed by the encoded TreeFilter (so
// `?tag=work` cache is independent of unfiltered), and the TTL is 60s.
type dashboardCache struct {
ttl time.Duration
mu sync.Mutex
rows map[string]cachedDashboard
}
type cachedDashboard struct {
at time.Time
payload *dashboardPayload
}
func newDashboardCache(ttl time.Duration) *dashboardCache {
return &dashboardCache{ttl: ttl, rows: map[string]cachedDashboard{}}
}
func (c *dashboardCache) get(key string) (*dashboardPayload, bool) {
if c == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.rows[key]
if !ok {
return nil, false
}
if time.Since(v.at) > c.ttl {
delete(c.rows, key)
return nil, false
}
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)
}
// invalidateAll wipes every cached payload. Used by writeback paths (Gitea
// close/comment/create, CalDAV completion) that can change content under any
// filter.
func (c *dashboardCache) invalidateAll() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows = map[string]cachedDashboard{}
}
func (c *dashboardCache) set(key string, p *dashboardPayload) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows[key] = cachedDashboard{at: time.Now(), payload: p}
}
// dashboardCache TTL — Phase 5b unified the per-cache types into the
// generic internal/cache.TTLCache[V]. Design note (design.md §9): every
// cache entry is keyed by the encoded TreeFilter (so `?tag=work` cache is
// independent of unfiltered).
const dashboardCacheTTL = 60 * time.Second
// dashboardPayload is the full aggregated view rendered into the page. Each
// slice is already sorted and capped at the per-card limit.
@@ -181,17 +122,17 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
// ?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)
s.dashboard.Invalidate(cacheKey)
}
payload, hit := s.dashboard.get(cacheKey)
payload, hit := s.dashboard.Get(cacheKey)
if !hit {
built, err := s.buildDashboard(r.Context(), filter)
if err != nil {
s.fail(w, r, err)
return
}
s.dashboard.set(cacheKey, built)
s.dashboard.Set(cacheKey, built)
payload = built
}
displayPayload := *payload
@@ -653,7 +594,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
}
if current == nil {
// Task already gone — drop cache + re-render so the row vanishes.
s.dashboard.invalidateAll()
s.dashboard.InvalidateAll()
s.handleDashboard(w, r)
return
}
@@ -692,7 +633,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
http.Error(w, "unknown action: "+action, http.StatusBadRequest)
return
}
s.dashboard.invalidateAll()
s.dashboard.InvalidateAll()
s.timeline.invalidateAll()
// Re-render whichever surface the request came from. HTMX sets HX-Target
// to the swap target's id; the timeline surface uses #timeline-section.

View File

@@ -100,7 +100,7 @@ func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path,
s.Gitea.Cache.Invalidate(repoRef + "|open")
s.Gitea.Cache.Invalidate(repoRef + "|closed-recent")
if s.dashboard != nil {
s.dashboard.invalidateAll()
s.dashboard.InvalidateAll()
}
if r.Header.Get("HX-Request") == "true" {

View File

@@ -86,7 +86,7 @@ func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path
}
// Bust the dashboard + timeline caches: a removed dated link should
// disappear from both surfaces on next render.
s.dashboard.invalidateAll()
s.dashboard.InvalidateAll()
s.timeline.invalidateAll()
// When the delete came from the timeline (HX-Target = timeline-section),
// re-render the timeline so the row vanishes in place instead of trying to

View File

@@ -15,6 +15,7 @@ import (
"time"
"github.com/m/projax/internal/aggregate"
"github.com/m/projax/internal/cache"
"github.com/m/projax/store"
)
@@ -42,7 +43,7 @@ type Server struct {
Gitea *GiteaDeps // nil → Gitea integration disabled
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
Version string // build-time -ldflags injection; surfaced on /admin
dashboard *dashboardCache
dashboard *cache.TTLCache[*dashboardPayload]
timeline *timelineCache
adminHealth *adminHealthCache
}
@@ -265,7 +266,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
Store: s,
pages: pages,
Logger: logger,
dashboard: newDashboardCache(60 * time.Second),
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
timeline: newTimelineCache(timelineCacheTTL),
adminHealth: newAdminHealthCache(),
}, nil