Files
apex-public/internal/tui/tracker_views.go
T
2026-07-06 11:05:50 -04:00

321 lines
8.3 KiB
Go

package tui
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/cobr-ai/apex/internal/tracker"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const trackerDailyCap = 25
type TrackerModel struct {
mgr *tracker.Manager
db *sql.DB
table table.Model
apps []tracker.Application
stats tracker.Stats
width int
height int
status string
mode trackerMode
advanceTo []tracker.Status
advanceCur int
noteInput textinput.Model
pending int
}
type trackerMode int
const (
tmList trackerMode = iota
tmAdvanceMenu
tmNoteEntry
)
func NewTrackerModel(db *sql.DB) *TrackerModel {
mgr := tracker.NewManager(db, trackerDailyCap, 7*24*time.Hour)
ti := textinput.New()
ti.Placeholder = "note (optional)…"
ti.CharLimit = 200
ti.Width = 60
m := &TrackerModel{db: db, mgr: mgr, noteInput: ti, status: "[r] refresh [a] advance [n] note [f] follow-ups"}
m.refresh()
m.table = newTrackerTable(m.apps, 0, 0)
return m
}
func (m *TrackerModel) refresh() {
apps, err := m.mgr.List("")
if err != nil {
m.status = fmt.Sprintf("list failed: %v", err)
return
}
m.apps = apps
stats, _ := m.mgr.Stats()
m.stats = stats
}
func trackerColumns() []table.Column {
return []table.Column{
{Title: "ID", Width: 4},
{Title: "Company", Width: 20},
{Title: "Title", Width: 28},
{Title: "Status", Width: 11},
{Title: "Score", Width: 5},
{Title: "Applied", Width: 10},
{Title: "Updated", Width: 10},
}
}
func newTrackerTable(rows []tracker.Application, w, h int) table.Model {
cols := flexColumns(w, trackerColumns(), []int{1, 2})
trows := make([]table.Row, 0, len(rows))
for _, a := range rows {
trows = append(trows, trackerRowToTable(a))
}
t := table.New(
table.WithColumns(cols),
table.WithRows(trows),
table.WithFocused(true),
table.WithHeight(tableHeight(h)),
)
s := table.DefaultStyles()
s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()).
BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt)
s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight)
t.SetStyles(s)
return t
}
func trackerRowToTable(a tracker.Application) table.Row {
score := "-"
if a.Score.Valid && a.Score.Float64 > 0 {
score = fmt.Sprintf("%.1f", a.Score.Float64)
}
applied := "-"
if a.AppliedAt.Valid {
applied = a.AppliedAt.Time.Local().Format("2006-01-02")
}
updated := "-"
if a.UpdatedAt.Valid {
updated = a.UpdatedAt.Time.Local().Format("2006-01-02")
}
return table.Row{
fmt.Sprintf("%d", a.ID),
a.Company,
a.Title,
string(a.Status), score, applied, updated,
}
}
func (m *TrackerModel) Init() tea.Cmd { return nil }
func (m *TrackerModel) Resize(w, h int) {
m.width = w
m.height = h
m.table.SetHeight(tableHeight(h))
m.table.SetWidth(w - 2)
m.table.SetColumns(flexColumns(w-2, trackerColumns(), []int{1, 2}))
}
func (m *TrackerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m.mode {
case tmAdvanceMenu:
return m.updateAdvanceMenu(msg)
case tmNoteEntry:
return m.updateNoteEntry(msg)
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "r":
m.refresh()
m.refreshTable()
m.status = fmt.Sprintf("%d apps · %d applied last 24h · %d pending follow-ups",
m.stats.Total, m.stats.AppliedLast24h, m.stats.OpenFollowUps)
case "a":
if cur := m.table.Cursor(); cur < len(m.apps) {
next := tracker.AllowedNext(m.apps[cur].Status)
if len(next) == 0 {
m.status = fmt.Sprintf("%s is terminal — no further moves", m.apps[cur].Status)
return m, nil
}
m.advanceTo = next
m.advanceCur = 0
m.mode = tmAdvanceMenu
m.status = "select target status (↑/↓ enter, esc cancel)"
}
case "n":
if cur := m.table.Cursor(); cur < len(m.apps) {
m.noteInput.SetValue("")
m.noteInput.Focus()
m.mode = tmNoteEntry
m.status = fmt.Sprintf("note for app #%d (enter to save, esc to cancel)", m.apps[cur].ID)
}
case "f":
pending, err := m.mgr.PendingFollowUps()
if err != nil {
m.status = fmt.Sprintf("follow_ups: %v", err)
return m, nil
}
m.pending = len(pending)
m.status = fmt.Sprintf("%d pending follow-ups", m.pending)
}
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *TrackerModel) updateAdvanceMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc", "q":
m.mode = tmList
m.status = "cancelled"
return m, nil
case "up", "k":
if m.advanceCur > 0 {
m.advanceCur--
}
case "down", "j":
if m.advanceCur < len(m.advanceTo)-1 {
m.advanceCur++
}
case "enter":
if cur := m.table.Cursor(); cur < len(m.apps) {
app := m.apps[cur]
target := m.advanceTo[m.advanceCur]
if err := m.mgr.Advance(app.ID, target, ""); err != nil {
switch {
case errors.Is(err, tracker.ErrRateLimited):
m.status = fmt.Sprintf("rate limit hit: %v", err)
case errors.Is(err, tracker.ErrInvalidTransition):
m.status = fmt.Sprintf("invalid: %v", err)
default:
m.status = fmt.Sprintf("advance failed: %v", err)
}
} else {
m.status = fmt.Sprintf("#%d %s → %s", app.ID, app.Status, target)
m.refresh()
m.refreshTable()
}
}
m.mode = tmList
}
}
return m, nil
}
func (m *TrackerModel) updateNoteEntry(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc":
m.mode = tmList
m.status = "note cancelled"
return m, nil
case "enter":
if cur := m.table.Cursor(); cur < len(m.apps) {
note := strings.TrimSpace(m.noteInput.Value())
if note == "" {
m.status = "empty note — discarded"
} else if err := m.mgr.AddNote(m.apps[cur].ID, note); err != nil {
m.status = fmt.Sprintf("add note failed: %v", err)
} else {
m.status = fmt.Sprintf("note added to #%d", m.apps[cur].ID)
}
}
m.mode = tmList
return m, nil
}
}
var cmd tea.Cmd
m.noteInput, cmd = m.noteInput.Update(msg)
return m, cmd
}
func (m *TrackerModel) refreshTable() {
trows := make([]table.Row, 0, len(m.apps))
for _, a := range m.apps {
trows = append(trows, trackerRowToTable(a))
}
m.table.SetRows(trows)
}
func (m *TrackerModel) View() string {
header := StyleTitle.Render("Tracker — application pipeline")
statsLine := fmt.Sprintf("%d apps · applied %d/%d (24h) · %d pending follow-ups",
m.stats.Total, m.stats.AppliedLast24h, trackerDailyCap, m.stats.OpenFollowUps)
sub := StyleSubtitle.Render(statsLine)
var body string
switch m.mode {
case tmAdvanceMenu:
body = m.renderAdvanceMenu()
case tmNoteEntry:
body = m.renderNoteEntry()
default:
body = m.table.View() + "\n\n" + m.renderStatusBreakdown()
}
status := StyleStatusMuted.Render(m.status)
return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status)
}
func (m *TrackerModel) renderAdvanceMenu() string {
var b strings.Builder
cur := m.table.Cursor()
if cur >= len(m.apps) {
return "no row selected"
}
app := m.apps[cur]
b.WriteString(fmt.Sprintf("Advance #%d (%s — %s)\n", app.ID, app.Company, app.Title))
b.WriteString(fmt.Sprintf("Current: %s\n\n", app.Status))
for i, s := range m.advanceTo {
marker := " "
line := string(s)
if i == m.advanceCur {
marker = "▌ "
line = lipgloss.NewStyle().Bold(true).Foreground(ColorAccentAlt).Render(line)
}
b.WriteString(marker + line + "\n")
}
b.WriteString("\n" + StyleKeyDesc.Render("[↑/↓] move [enter] confirm [esc] cancel"))
return StyleModal.Render(b.String())
}
func (m *TrackerModel) renderNoteEntry() string {
var b strings.Builder
cur := m.table.Cursor()
if cur < len(m.apps) {
app := m.apps[cur]
b.WriteString(fmt.Sprintf("Add note to #%d (%s — %s)\n\n", app.ID, app.Company, app.Title))
}
b.WriteString(m.noteInput.View())
b.WriteString("\n\n" + StyleKeyDesc.Render("[enter] save [esc] cancel"))
return StyleModal.Render(b.String())
}
func (m *TrackerModel) renderStatusBreakdown() string {
if len(m.stats.ByStatus) == 0 {
return ""
}
var parts []string
for _, s := range tracker.AllStatuses {
n := m.stats.ByStatus[s]
if n == 0 {
continue
}
parts = append(parts, fmt.Sprintf("%s %d", string(s), n))
}
return StyleStatusMuted.Render("breakdown — " + strings.Join(parts, " · "))
}