Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions cmd/list_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
34 changes: 34 additions & 0 deletions internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}

Expand All @@ -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{
Expand Down Expand Up @@ -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
}
}
Expand Down
93 changes: 93 additions & 0 deletions internal/service/stats_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
8 changes: 8 additions & 0 deletions internal/tui/list_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 8 additions & 5 deletions internal/tui/list_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
Expand Down
Loading