initial public release
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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, " · "))
|
||||
}
|
||||
Reference in New Issue
Block a user