From 8d6c27f2cb3ce66a06a6460162e7921b95bc769d Mon Sep 17 00:00:00 2001 From: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:41:59 -0400 Subject: [PATCH] feat: add new statle data indicator Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> --- cmd/list.go | 36 +++++++++++-- cmd/list_test.go | 28 ++++++++++ internal/service/service.go | 34 +++++++++++++ internal/service/stats_test.go | 93 ++++++++++++++++++++++++++++++++++ internal/tui/list_model.go | 8 +++ internal/tui/list_view.go | 13 +++-- 6 files changed, 204 insertions(+), 8 deletions(-) create mode 100644 cmd/list_test.go create mode 100644 internal/service/stats_test.go diff --git a/cmd/list.go b/cmd/list.go index e2d035a..726f1cb 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -48,9 +48,33 @@ func sendFetchCompleteEvent(result *service.FetchResult, err error, sinceLabel s } else if stats.NotifFromCache { fetchMsg = fmt.Sprintf("for the past %s (%d items, cached)", sinceLabel, totalFetched) } + + if stats.AnyFromCache() { + age := stats.CacheAge() + fetchMsg += fmt.Sprintf(" — showing cached data from %s ago", formatCacheAge(age)) + log.Warn("showing cached data", "age", formatCacheAge(age)) + } + sendTaskEvent(events, tui.TaskFetch, tui.StatusComplete, tui.WithMessage(fetchMsg)) } +// formatCacheAge formats a duration into a human-readable age string. +func formatCacheAge(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + default: + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + if m == 0 { + return fmt.Sprintf("%dh", h) + } + return fmt.Sprintf("%dh%dm", h, m) + } +} + // sendRateLimitEvent sends a rate limit TUI event if the result indicates rate limiting. func sendRateLimitEvent(result *service.FetchResult, events chan tui.Event) { if !result.RateLimited || events == nil { @@ -204,7 +228,7 @@ func runList(cmd *cobra.Command, opts *Options) error { // Output rt.close() - return renderOutput(items, opts, cfg, svc.CurrentUser(), resolvedStore) + return renderOutput(items, opts, cfg, svc.CurrentUser(), resolvedStore, stats) } // setupRuntime creates the runtime struct and returns a cleanup function for profiling. @@ -370,7 +394,7 @@ func processResults(result *service.FetchResult, cfg *config.Config, currentUser } // renderOutput determines the format and outputs the results. -func renderOutput(items []triage.PrioritizedItem, opts *Options, cfg *config.Config, currentUser string, resolvedStore *resolved.Store) error { +func renderOutput(items []triage.PrioritizedItem, opts *Options, cfg *config.Config, currentUser string, resolvedStore *resolved.Store, stats service.FetchStats) error { format := output.Format(opts.Format) if format == "" { format = output.Format(cfg.DefaultFormat) @@ -380,7 +404,13 @@ func renderOutput(items []triage.PrioritizedItem, opts *Options, cfg *config.Con if shouldUseTUI(opts) && (format == "" || format == output.FormatTable) { weights := cfg.GetScoreWeights() blockedLabels := cfg.GetBlockedLabels() - return tui.RunListUI(items, resolvedStore, weights, currentUser, tui.WithConfig(cfg), tui.WithBlockedLabels(blockedLabels)) + tuiOpts := []tui.ListOption{tui.WithConfig(cfg), tui.WithBlockedLabels(blockedLabels)} + if stats.AnyFromCache() { + tuiOpts = append(tuiOpts, tui.WithCacheStatus( + fmt.Sprintf("Showing cached data from %s ago", formatCacheAge(stats.CacheAge())), + )) + } + return tui.RunListUI(items, resolvedStore, weights, currentUser, tuiOpts...) } // Filter out resolved items for non-TUI output (TUI handles this internally) diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..a7dbc62 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "testing" + "time" +) + +func TestFormatCacheAge(t *testing.T) { + tests := []struct { + name string + duration time.Duration + want string + }{ + {"seconds", 45 * time.Second, "45s"}, + {"minutes", 12 * time.Minute, "12m"}, + {"exact hours", 3 * time.Hour, "3h"}, + {"hours and minutes", 2*time.Hour + 30*time.Minute, "2h30m"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatCacheAge(tt.duration) + if got != tt.want { + t.Errorf("formatCacheAge(%v) = %q, want %q", tt.duration, got, tt.want) + } + }) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 15b954d..6872332 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -22,6 +22,23 @@ type FetchStats struct { AssignedFromCache bool AssignedPRsFromCache bool OrphanedFromCache bool + // OldestCachedAt is the oldest CachedAt timestamp across all cache-served + // sources. Zero if nothing was served from cache. + OldestCachedAt time.Time +} + +// AnyFromCache returns true if any data source was served from cache. +func (s FetchStats) AnyFromCache() bool { + return s.NotifFromCache || s.ReviewFromCache || s.AuthoredFromCache || + s.AssignedFromCache || s.AssignedPRsFromCache || s.OrphanedFromCache +} + +// CacheAge returns the age of the oldest cached data. Zero if nothing was cached. +func (s FetchStats) CacheAge() time.Duration { + if s.OldestCachedAt.IsZero() { + return 0 + } + return time.Since(s.OldestCachedAt) } // ItemService orchestrates data flow between GitHub API and cache. @@ -65,6 +82,15 @@ func (s *ItemService) recordStat(fn func(*FetchStats)) { s.statsMu.Unlock() } +// recordCachedAt updates OldestCachedAt if t is older than the current value. +func (s *ItemService) recordCachedAt(t time.Time) { + s.statsMu.Lock() + if s.fetchStats.OldestCachedAt.IsZero() || t.Before(s.fetchStats.OldestCachedAt) { + s.fetchStats.OldestCachedAt = t + } + s.statsMu.Unlock() +} + // ItemFetchResult contains the result of a cached item fetch. type ItemFetchResult struct { Items []model.Item @@ -79,6 +105,7 @@ func (s *ItemService) ReviewRequestedPRs(ctx context.Context) ([]model.Item, boo if s.cache != nil { if entry, ok := s.cache.GetList(s.currentUser, cache.ListTypeReviewRequested, cache.ListOptions{}); ok { s.recordStat(func(st *FetchStats) { st.ReviewFromCache = true }) + s.recordCachedAt(entry.CachedAt) return entry.Items, true, nil } } @@ -115,6 +142,7 @@ func (s *ItemService) AuthoredPRs(ctx context.Context) ([]model.Item, bool, erro if s.cache != nil { if entry, ok := s.cache.GetList(s.currentUser, cache.ListTypeAuthored, cache.ListOptions{}); ok { s.recordStat(func(st *FetchStats) { st.AuthoredFromCache = true }) + s.recordCachedAt(entry.CachedAt) return entry.Items, true, nil } } @@ -151,6 +179,7 @@ func (s *ItemService) AssignedIssues(ctx context.Context) ([]model.Item, bool, e if s.cache != nil { if entry, ok := s.cache.GetList(s.currentUser, cache.ListTypeAssignedIssues, cache.ListOptions{}); ok { s.recordStat(func(st *FetchStats) { st.AssignedFromCache = true }) + s.recordCachedAt(entry.CachedAt) return entry.Items, true, nil } } @@ -187,6 +216,7 @@ func (s *ItemService) AssignedPRs(ctx context.Context) ([]model.Item, bool, erro if s.cache != nil { if entry, ok := s.cache.GetList(s.currentUser, cache.ListTypeAssignedPRs, cache.ListOptions{}); ok { s.recordStat(func(st *FetchStats) { st.AssignedPRsFromCache = true }) + s.recordCachedAt(entry.CachedAt) return entry.Items, true, nil } } @@ -237,6 +267,7 @@ func (s *ItemService) UnreadItems(ctx context.Context, includeRead bool) (*ItemF result.Items = entry.Items result.FromCache = true s.recordStat(func(st *FetchStats) { st.NotifFromCache = true }) + s.recordCachedAt(entry.CachedAt) return result, nil } } @@ -254,6 +285,7 @@ func (s *ItemService) UnreadItems(ctx context.Context, includeRead bool) (*ItemF result.Items = entry.Items result.FromCache = true s.recordStat(func(st *FetchStats) { st.NotifFromCache = true }) + s.recordCachedAt(entry.CachedAt) return result, nil } @@ -266,6 +298,7 @@ func (s *ItemService) UnreadItems(ctx context.Context, includeRead bool) (*ItemF st.NotifFromCache = true st.NotifNewCount = len(newItems) }) + s.recordCachedAt(entry.CachedAt) // Update cache with merged result if err := s.cache.SetList(s.currentUser, cache.ListTypeNotifications, &cache.ListCacheEntry{ @@ -329,6 +362,7 @@ func (s *ItemService) OrphanedContributions(ctx context.Context, opts ghclient.O if s.cache != nil { if entry, ok := s.cache.GetList(s.currentUser, cache.ListTypeOrphaned, cacheOpts); ok { s.recordStat(func(st *FetchStats) { st.OrphanedFromCache = true }) + s.recordCachedAt(entry.CachedAt) return entry.Items, true, nil } } diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go new file mode 100644 index 0000000..6b094e2 --- /dev/null +++ b/internal/service/stats_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "testing" + "time" +) + +func TestFetchStats_AnyFromCache(t *testing.T) { + tests := []struct { + name string + stats FetchStats + want bool + }{ + {"none cached", FetchStats{}, false}, + {"notif cached", FetchStats{NotifFromCache: true}, true}, + {"review cached", FetchStats{ReviewFromCache: true}, true}, + {"authored cached", FetchStats{AuthoredFromCache: true}, true}, + {"assigned cached", FetchStats{AssignedFromCache: true}, true}, + {"assigned PRs cached", FetchStats{AssignedPRsFromCache: true}, true}, + {"orphaned cached", FetchStats{OrphanedFromCache: true}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.stats.AnyFromCache(); got != tt.want { + t.Errorf("AnyFromCache() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRecordCachedAt(t *testing.T) { + t.Run("first call sets the value", func(t *testing.T) { + svc := New(nil, nil, "testuser", time.Now()) + ts := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + + svc.recordCachedAt(ts) + + got := svc.Stats().OldestCachedAt + if !got.Equal(ts) { + t.Errorf("OldestCachedAt = %v, want %v", got, ts) + } + }) + + t.Run("older time updates the value", func(t *testing.T) { + svc := New(nil, nil, "testuser", time.Now()) + first := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + older := first.Add(-1 * time.Hour) + + svc.recordCachedAt(first) + svc.recordCachedAt(older) + + got := svc.Stats().OldestCachedAt + if !got.Equal(older) { + t.Errorf("OldestCachedAt = %v, want %v", got, older) + } + }) + + t.Run("newer time does not update the value", func(t *testing.T) { + svc := New(nil, nil, "testuser", time.Now()) + first := time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + older := first.Add(-1 * time.Hour) + newer := first.Add(1 * time.Hour) + + svc.recordCachedAt(first) + svc.recordCachedAt(older) + svc.recordCachedAt(newer) + + got := svc.Stats().OldestCachedAt + if !got.Equal(older) { + t.Errorf("OldestCachedAt = %v, want oldest %v", got, older) + } + }) +} + +func TestFetchStats_CacheAge(t *testing.T) { + t.Run("zero when not cached", func(t *testing.T) { + s := FetchStats{} + if got := s.CacheAge(); got != 0 { + t.Errorf("CacheAge() = %v, want 0", got) + } + }) + + t.Run("returns age when cached", func(t *testing.T) { + s := FetchStats{ + OldestCachedAt: time.Now().Add(-5 * time.Minute), + } + age := s.CacheAge() + if age < 4*time.Minute || age > 6*time.Minute { + t.Errorf("CacheAge() = %v, want ~5m", age) + } + }) +} diff --git a/internal/tui/list_model.go b/internal/tui/list_model.go index f18f9a7..846eade 100644 --- a/internal/tui/list_model.go +++ b/internal/tui/list_model.go @@ -143,6 +143,7 @@ type ListModel struct { windowHeight int statusMsg string statusTime time.Time + cacheMsg string // persistent cache staleness indicator quitting bool hotTopicThreshold int prSizeXS int @@ -181,6 +182,13 @@ func WithConfig(cfg *config.Config) ListOption { } } +// WithCacheStatus sets a persistent cache staleness message shown in the footer. +func WithCacheStatus(msg string) ListOption { + return func(m *ListModel) { + m.cacheMsg = msg + } +} + // WithBlockedLabels sets the labels used to identify blocked items. // If empty, the blocked pane is effectively disabled. func WithBlockedLabels(labels []string) ListOption { diff --git a/internal/tui/list_view.go b/internal/tui/list_view.go index 181219e..99ae849 100644 --- a/internal/tui/list_view.go +++ b/internal/tui/list_view.go @@ -254,15 +254,15 @@ func renderListView(m ListModel) string { b.WriteString("\n") } - // Render footer - b.WriteString("\n") - b.WriteString(renderHelp(m.TypeFilterLabel(), m.showDone)) - - // Status message (always render the line to keep view height constant) + // Render footer: cache/status line above help b.WriteString("\n") if m.statusMsg != "" { b.WriteString(listStatusStyle.Render(m.statusMsg)) + } else if m.cacheMsg != "" { + b.WriteString(listCacheStyle.Render(m.cacheMsg)) } + b.WriteString("\n") + b.WriteString(renderHelp(m.TypeFilterLabel(), m.showDone)) return b.String() } @@ -863,6 +863,9 @@ var ( listStatusStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#60A5FA")) + listCacheStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")) + listEmptyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#6B7280")). Italic(true)