321 lines
8.3 KiB
Go
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, " · "))
|
|
}
|