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

Phase 5b slice C. Mirror of slice B for the timeline cache:
timelineCache + cachedTimeline + newTimelineCache deleted. The Server's
timeline field is now `*cache.TTLCache[*TimelinePayload]` constructed
via `cache.NewTTL[*TimelinePayload](timelineCacheTTL)`. Call sites
across web/{timeline,caldav,dashboard,links}.go renamed:

- s.timeline.get(k)        → s.timeline.Get(k)
- s.timeline.set(k, p)     → s.timeline.Set(k, p)
- s.timeline.invalidateAll → s.timeline.InvalidateAll
- (timeline never used keyed invalidate, so no .Invalidate rename)

Removes the unused `sync` import from web/timeline.go. The 50-line
timelineCache struct + four methods are gone; the file shrinks by
~50 lines.

All web/timeline_*test.go pass unmodified.

Task: t-projax-5b-cache
This commit is contained in:
mAi
2026-05-22 00:27:08 +02:00
parent 085e672dd5
commit d518978edb
5 changed files with 11 additions and 62 deletions

View File

@@ -393,7 +393,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
s.dashboard.InvalidateAll()
}
if s.timeline != nil {
s.timeline.invalidateAll()
s.timeline.InvalidateAll()
}
// Always re-render the tasks section so HTMX (or a plain redirect for
// non-HTMX clients) sees the post-write state.

View File

@@ -634,7 +634,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
return
}
s.dashboard.InvalidateAll()
s.timeline.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.
// Non-HTMX clients fall through to the dashboard re-render.

View File

@@ -51,7 +51,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
}
}
// New dated link → bust the timeline cache so the row surfaces on next view.
s.timeline.invalidateAll()
s.timeline.InvalidateAll()
s.renderDocumentsSection(w, r, it, nil, banner)
}
@@ -87,7 +87,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.timeline.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
// swap a Documents fragment into it.

View File

@@ -44,7 +44,7 @@ type Server struct {
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
Version string // build-time -ldflags injection; surfaced on /admin
dashboard *cache.TTLCache[*dashboardPayload]
timeline *timelineCache
timeline *cache.TTLCache[*TimelinePayload]
adminHealth *adminHealthCache
}
@@ -267,7 +267,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
pages: pages,
Logger: logger,
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
timeline: newTimelineCache(timelineCacheTTL),
timeline: cache.NewTTL[*TimelinePayload](timelineCacheTTL),
adminHealth: newAdminHealthCache(),
}, nil
}

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/m/projax/internal/aggregate"
@@ -13,7 +12,8 @@ import (
)
// Timeline cache TTL — looser than the dashboard's 60s because /timeline is
// browse-y rather than action-y. Per filter+window key.
// browse-y rather than action-y. Per filter+window key. Phase 5b unified
// onto internal/cache.TTLCache[V].
const timelineCacheTTL = 90 * time.Second
// timelineKindTodo / Event / Doc / Creation are the four filterable row
@@ -33,57 +33,6 @@ const (
timelineDefaultFutureDays = 90
)
// timelineCache holds aggregated payloads per (filter, window, order) key.
type timelineCache struct {
ttl time.Duration
mu sync.Mutex
rows map[string]cachedTimeline
}
type cachedTimeline struct {
at time.Time
payload *TimelinePayload
}
func newTimelineCache(ttl time.Duration) *timelineCache {
return &timelineCache{ttl: ttl, rows: map[string]cachedTimeline{}}
}
func (c *timelineCache) get(key string) (*TimelinePayload, 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 *timelineCache) set(key string, p *TimelinePayload) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows[key] = cachedTimeline{at: time.Now(), payload: p}
}
func (c *timelineCache) invalidateAll() {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.rows = map[string]cachedTimeline{}
}
// TimelineRow is one entry on the chronological spine. Re-exported from
// internal/aggregate so MCP + web share one row shape. (Pre-Phase-5a
// web/timeline.go defined its own copy; the aggregator package now owns
@@ -224,16 +173,16 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
key := q.cacheKey()
if r.URL.Query().Get("refresh") == "1" {
s.timeline.invalidateAll()
s.timeline.InvalidateAll()
}
payload, hit := s.timeline.get(key)
payload, hit := s.timeline.Get(key)
if !hit {
built, err := s.buildTimeline(r.Context(), q, now)
if err != nil {
s.fail(w, r, err)
return
}
s.timeline.set(key, built)
s.timeline.Set(key, built)
payload = built
}
display := *payload