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(glamourStyle()), 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, } }