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, " · ")) }