Files
nib-v1/internal/tui/stumble.go
T
lerko a567b2ce73 feat(tui): stumble mode — resurface stale entries card by card
Card-by-card walkthrough of entries untouched for 30+ days.
Prevents write-mostly decay by bringing old entries back to attention.

- S from list triggers stumble, loads entries where modified_at < 30d
- Single-card view with markdown body, glyph, tags, age indicator
- Actions: n skip, d dismiss, ! pin, p promote, m absorb, esc exit
- Progress indicator: stumble [3/12]
- After promote/absorb from stumble, returns to deck (not list)
- "All caught up" screen when deck exhausted
- DB: add ModifiedBefore to ListParams, modified_at sort column
2026-05-20 16:40:40 -04:00

165 lines
3.5 KiB
Go

package tui
import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/glamour"
"github.com/lerko/nib/internal/db"
"github.com/lerko/nib/internal/display"
)
const staleThresholdDays = 30
type stumbleModel struct {
entries []*db.Entity
cursor int
width int
height int
done bool
}
func newStumbleModel() stumbleModel {
return stumbleModel{}
}
func (s *stumbleModel) setEntries(entries []*db.Entity) {
s.entries = entries
s.cursor = 0
s.done = len(entries) == 0
}
func (s *stumbleModel) setSize(width, height int) {
s.width = width
s.height = height
}
func (s stumbleModel) current() *db.Entity {
if s.done || len(s.entries) == 0 || s.cursor >= len(s.entries) {
return nil
}
return s.entries[s.cursor]
}
func (s *stumbleModel) advance() {
s.cursor++
if s.cursor >= len(s.entries) {
s.done = true
}
}
func (s *stumbleModel) removeCurrent() {
if s.cursor < len(s.entries) {
s.entries = append(s.entries[:s.cursor], s.entries[s.cursor+1:]...)
if s.cursor >= len(s.entries) {
s.done = true
}
}
}
func (s stumbleModel) total() int {
return len(s.entries)
}
func (s stumbleModel) view() string {
if s.done {
return s.doneView()
}
e := s.current()
if e == nil {
return s.doneView()
}
w := s.width
var b strings.Builder
progress := fmt.Sprintf("stumble [%d/%d]", s.cursor+1, len(s.entries))
b.WriteString(detailHeaderStyle.Render(progress))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
b.WriteString("\n\n")
glyph := display.DisplayGlyph(e.Glyph, e.CardType)
title := e.Body
if e.Title != nil {
title = *e.Title
}
if len(title) > w-6 {
title = title[:w-9] + "…"
}
b.WriteString(" " + glyphStyle.Render(glyph) + " " + title)
b.WriteString("\n")
var meta []string
meta = append(meta, string(e.Glyph))
if e.CardType != nil {
meta = append(meta, string(*e.CardType))
}
for _, t := range e.Tags {
meta = append(meta, tagStyle.Render("#"+t))
}
meta = append(meta, "captured "+e.CreatedAt.Format("Jan 2"))
b.WriteString(" " + idStyle.Render(strings.Join(meta, " · ")))
b.WriteString("\n\n")
bodyWidth := w - 6
if bodyWidth < 20 {
bodyWidth = 20
}
r, _ := glamour.NewTermRenderer(
glamour.WithStylePath("dark"),
glamour.WithWordWrap(bodyWidth),
)
rendered, err := r.Render(e.Body)
if err != nil {
rendered = e.Body
}
rendered = strings.TrimRight(rendered, "\n")
b.WriteString(" " + rendered)
b.WriteString("\n\n")
age := daysAgo(e.ModifiedAt)
ageText := fmt.Sprintf("last touched %d days ago", age)
b.WriteString(" " + stumbleAgeStyle.Render(ageText))
b.WriteString("\n")
b.WriteString(separatorStyle.Render(strings.Repeat("─", w)))
lines := strings.Split(b.String(), "\n")
if len(lines) > s.height {
lines = lines[:s.height]
}
return strings.Join(lines, "\n")
}
func (s stumbleModel) doneView() string {
var b strings.Builder
b.WriteString("\n\n")
b.WriteString(detailHeaderStyle.Render(" all caught up"))
b.WriteString("\n\n")
reviewed := s.total()
if reviewed > 0 {
b.WriteString(idStyle.Render(fmt.Sprintf(" %d entries reviewed", reviewed)))
} else {
b.WriteString(idStyle.Render(" no stale entries found"))
}
return b.String()
}
func daysAgo(t time.Time) int {
return int(math.Floor(time.Since(t).Hours() / 24))
}
func staleParams() db.ListParams {
threshold := time.Now().UTC().AddDate(0, 0, -staleThresholdDays)
return db.ListParams{
ModifiedBefore: &threshold,
Sort: "modified_at",
Order: "asc",
Limit: 50,
}
}