charmite/model.go

480 lines
11 KiB
Go
Raw Normal View History

2025-06-17 16:39:51 +02:00
package main
import (
"charmcal/mite"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// June 2025
// Su Mo Tu We Th Fr Sa
// 1 2 3 4 5 6 7
// 8 9 10 11 12 13 14
// 15 16 17 18 19 20 21
// 22 23 24 25 26 27 28
// 29 30
var styleLocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) // red
var styleTime = lipgloss.NewStyle().Foreground(lipgloss.Color("#22ff4d")) // green
2025-06-18 11:06:12 +02:00
var styleStatusBar = lipgloss.NewStyle().Background(lipgloss.Color("#00dda1")).Foreground(lipgloss.Color("#555555"))
type tuiMode string
const MODE_CAL tuiMode = "MODE_CAL"
const MODE_TIMEENTRIES tuiMode = "MODE_TIMEENTRIES"
const MODE_FORMENTRY tuiMode = "MODE_FORMENTRY"
2025-06-17 16:39:51 +02:00
type model struct {
2025-06-18 11:06:12 +02:00
miteAPI mite.APIClient
start calendarTime
dest calendarTime
fetchedData bool // done initial fetch to get customers/projects etc
tuiMode tuiMode //
debug []string
formData struct {
2025-06-17 16:39:51 +02:00
form *huh.Form
customers mite.Customers
services mite.Services
projects mite.Projects
selected struct {
customer string
}
}
timeData struct {
entries mite.TimeEntries
table table.Model
}
2025-06-18 11:06:12 +02:00
statusBarMessage string
windowWidth int
2025-06-17 16:39:51 +02:00
}
func initialModel(miteDomain, miteApiKey string) model {
m := model{}
m.miteAPI = mite.NewClient(miteDomain, miteApiKey)
// table for time entries
tab := table.New(table.WithHeight(8))
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
tab.SetStyles(s)
tab.SetColumns([]table.Column{
table.Column{Title: "min", Width: 5},
table.Column{Title: "lck", Width: 4},
table.Column{Title: "customer", Width: 10},
table.Column{Title: "description", Width: 40},
})
m.start = calendarTime{time.Now()}
m.dest = calendarTime{time.Now()}
m.debug = make([]string, 5, 5)
2025-06-18 11:06:12 +02:00
m.tuiMode = MODE_CAL
2025-06-17 16:39:51 +02:00
// m.formData.form = form
m.timeData.table = tab
2025-06-18 11:06:12 +02:00
m.statusBarMessage = "Fetching data from API"
2025-06-17 16:39:51 +02:00
return m
}
func (m model) buildForm() *huh.Form {
clOptions := []huh.Option[string]{}
for _, cust := range m.formData.customers {
op := miteToHuhOption(cust)
clOptions = append(clOptions, op)
}
svcOptions := []huh.Option[string]{}
for _, svc := range m.formData.services {
op := miteToHuhOption(svc)
svcOptions = append(svcOptions, op)
}
cl := huh.NewSelect[string]().
Key("client").
Options(clOptions...).
Title("Client").Height(5).Value(&m.formData.selected.customer)
sl := huh.NewSelect[string]().
Key("service").
Options(svcOptions...).
Title("Service").Height(5)
pl := huh.NewSelect[string]().
Key("project").
Title("Project").Height(3).
OptionsFunc(func() []huh.Option[string] {
out := []huh.Option[string]{}
for _, proj := range m.formData.projects {
if fmt.Sprint(proj.CustomerID) == m.formData.selected.customer {
out = append(out, miteToHuhOption(proj))
}
}
return out
}, &m.formData.selected.customer)
form := huh.NewForm(
huh.NewGroup(
cl,
sl,
pl,
),
huh.NewGroup(
huh.NewText().
Key("description").
Title("description").
Validate(func(s string) error {
if s == "" {
return errors.New("must enter a description")
}
return nil
}),
huh.NewInput().
2025-06-18 11:06:12 +02:00
Key("minutes").
2025-06-17 16:39:51 +02:00
CharLimit(5).
Validate(
func(s string) error {
2025-06-18 11:06:12 +02:00
h, err := strconv.ParseInt(s, 10, 64)
2025-06-17 16:39:51 +02:00
if err != nil {
return err
}
if h < 0 {
return errors.New("must be positive")
}
return err
}).
2025-06-18 11:06:12 +02:00
Title("Minutes"),
2025-06-17 16:39:51 +02:00
),
)
return form
}
func miteToHuhOption[T mite.APIObject](i T) huh.Option[string] {
return huh.Option[string]{
Key: i.GetName(),
Value: fmt.Sprint(i.GetID()),
}
}
func (m model) Init() tea.Cmd {
return m.fetchMiteData()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2025-06-18 11:06:12 +02:00
// things we process no matter the tuiMode
switch msg := msg.(type) {
case miteDataFetchedMsg:
if msg.Error != nil {
m.statusBarMessage = fmt.Sprintf("Error fetching: %s", msg.Error.Error())
m.fetchedData = false
} else {
m.statusBarMessage = fmt.Sprintf("Fetched %d time entries", len(msg.TimeEntries))
m.timeData.entries = msg.TimeEntries
m.formData.customers = msg.Customers
m.formData.services = msg.Services
m.formData.projects = msg.Projects
m.fetchedData = true
// just in case there is data for the currently focused day
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
}
return m, nil
}
2025-06-17 16:39:51 +02:00
if len(m.debug) > 5 {
m.debug = m.debug[len(m.debug)-5:]
}
m.debug = append(m.debug, fmt.Sprintf("Got a %#v", msg))
2025-06-18 11:06:12 +02:00
if m.tuiMode == MODE_TIMEENTRIES {
2025-06-17 16:39:51 +02:00
return m.updateEntries(msg)
2025-06-18 11:06:12 +02:00
} else if m.tuiMode == MODE_CAL {
2025-06-17 16:39:51 +02:00
return m.updateCal(msg)
2025-06-18 11:06:12 +02:00
} else if m.tuiMode == MODE_FORMENTRY {
2025-06-17 16:39:51 +02:00
return m.updateForm(msg)
} else {
2025-06-18 11:06:12 +02:00
panic(m.tuiMode)
2025-06-17 16:39:51 +02:00
}
}
// func (m model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) {
// // if esc pushed, switch back
// if key, ok := msg.(tea.KeyMsg); ok {
// if key.String() == "esc" {
// m.focus = "cal"
// // m._client.Blur()
// return m, nil
// }
// }
// form, cmd := m.form.Update(msg)
// if f, ok := form.(*huh.Form); ok {
// m.form = f
// }
// if m.form.State == huh.StateCompleted {
// class := m.form.GetString("client")
// hours := m.form.GetString("hours")
// panic(fmt.Sprintf("You selected: %s, Lvl. %s", class, hours))
// }
// return m, cmd
// }
2025-06-18 11:06:12 +02:00
// updateEntries handles processing tea messages when the days time entries are being
// processed
2025-06-17 16:39:51 +02:00
func (m model) updateEntries(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
2025-06-18 11:06:12 +02:00
m.tuiMode = MODE_CAL
2025-06-17 16:39:51 +02:00
m.timeData.table.Blur()
return m, nil
case "ctrl+c", "q":
return m, tea.Quit
case "a":
2025-06-18 11:06:12 +02:00
m.tuiMode = MODE_FORMENTRY
2025-06-17 16:39:51 +02:00
m.formData.form = m.buildForm()
m.formData.form.Init()
return m, nil
}
}
newTable, tableCmd := m.timeData.table.Update(msg)
m.timeData.table = newTable
return m, tableCmd
}
func (m model) updateCal(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.windowWidth = msg.Width
// Is it a key press?
case tea.KeyMsg:
// Cool, what was the actual key pressed?
switch msg.String() {
case "a":
2025-06-18 11:06:12 +02:00
if m.fetchedData {
m.tuiMode = MODE_FORMENTRY
m.formData.form = m.buildForm()
m.formData.form.Init()
} else {
m.statusBarMessage = "Not yet fetched data"
}
2025-06-17 16:39:51 +02:00
return m, nil
case "tab":
2025-06-18 11:06:12 +02:00
m.tuiMode = MODE_TIMEENTRIES
2025-06-17 16:39:51 +02:00
m.timeData.table.Focus()
m.timeData.table.SetCursor(0)
return m, nil
// These keys should exit the program.
case "ctrl+c", "q":
return m, tea.Quit
// time entries
case "f":
2025-06-18 11:06:12 +02:00
m.statusBarMessage = "Fetching data from API"
2025-06-17 16:39:51 +02:00
return m, m.fetchMiteData()
// // The "up" and "k" keys move the cursor up
// case "up", "k":
// if m.cursor > 0 {
// m.cursor--
// }
// // The "down" and "j" keys move the cursor down
// case "down", "j":
// if m.cursor < len(m.choices)-1 {
// m.cursor++
// }
case "left", "h":
m.dest = m.dest.subDay()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "down", "j":
m.dest = m.dest.addWeek()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "up", "k":
m.dest = m.dest.subWeek()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "right", "l":
m.dest = m.dest.addDay()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "[":
m.dest = m.dest.subMonth()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "]":
m.dest = m.dest.addMonth()
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
case "t":
m.start = calendarTime{time.Now()}
m.dest = calendarTime{time.Now()}
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
return m, nil
}
// The "enter" key and the spacebar (a literal space) toggle
// the selected state for the item that the cursor is pointing at.
// case "enter", " ":
// _, ok := m.selected[m.cursor]
// if ok {
// delete(m.selected, m.cursor)
// } else {
// m.selected[m.cursor] = struct{}{}
// }
// }
default:
m.debug = append(m.debug, fmt.Sprintf("cal focus got: %#v", msg))
}
// Return the updated model to the Bubble Tea runtime for processing.
// Note that we're not returning a command.
return m, nil
}
func (m model) tableDataForDate(t time.Time) []table.Row {
// populate the table
te := m.timeData.entries.ByDate(m.dest.Time)
rows := []table.Row{}
for _, entry := range te {
lock := " "
if entry.Locked {
lock = "🔐"
}
rows = append(rows, table.Row{fmt.Sprint(entry.Minutes), lock, entry.CustomerName, entry.Note})
}
return rows
}
type miteDataFetchedMsg struct {
TimeEntries mite.TimeEntries
Customers mite.Customers
Services mite.Services
Projects mite.Projects
Error error
}
func (m model) fetchMiteData() tea.Cmd {
return func() tea.Msg {
2025-06-18 12:30:04 +02:00
from := time.Now().Add(-time.Hour * 24 * 30 * 6) // about 6 months
to := time.Now().Add(time.Hour * 20 * 30) // about 1 month
te, err1 := m.miteAPI.GetTimeEntries(from, to)
2025-06-17 16:39:51 +02:00
cst, err2 := m.miteAPI.GetCustomers()
svc, err3 := m.miteAPI.GetServices()
pjt, err4 := m.miteAPI.GetProjects()
return miteDataFetchedMsg{
TimeEntries: te,
Customers: cst,
Services: svc,
Projects: pjt,
Error: errors.Join(err1, err2, err3, err4),
}
}
}
var subs = [11]rune{'₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉', '₊'}
func (m model) View() string {
lhs := strings.Builder{}
rhs := strings.Builder{}
// if any entries are not "locked", we always use that
// regular colour in preference
styles := map[string]lipgloss.Style{}
unlocked := map[string]bool{}
for _, entry := range m.timeData.entries {
_, hasUnlocked := unlocked[entry.DateAt]
if entry.Locked && !hasUnlocked {
styles[entry.DateAt] = styleLocked
} else {
styles[entry.DateAt] = styleTime
if entry.Locked {
unlocked[entry.DateAt] = true
}
}
}
lhs.WriteString(calendar(m.dest, m.start, m.dest, styles))
lhs.WriteString("nav: ←↑→↓[] (t)oday\n")
2025-06-18 11:06:12 +02:00
if m.tuiMode == MODE_CAL {
lhs.WriteString("(f)etch time data\n")
} else {
lhs.WriteString("\n")
}
if m.tuiMode != MODE_FORMENTRY {
lhs.WriteString("(a)dd time entry\n")
} else {
lhs.WriteString("\n")
}
if m.tuiMode == MODE_FORMENTRY {
lhs.WriteString("(esc) abort form\n")
lhs.WriteString("\n")
} else {
lhs.WriteString("(tab) switch panes\n")
lhs.WriteString("(q)uit\n")
}
2025-06-17 16:39:51 +02:00
calendarWidth := 25
tableWidth := m.windowWidth - calendarWidth
2025-06-18 11:06:12 +02:00
if m.tuiMode == MODE_FORMENTRY {
rhs.WriteString(m.formData.form.View())
2025-06-17 16:39:51 +02:00
} else {
2025-06-18 11:06:12 +02:00
if m.fetchedData {
rhs.WriteString(m.timeData.table.View())
rhs.WriteString("\n")
2025-06-17 16:39:51 +02:00
}
}
out := lipgloss.JoinHorizontal(
lipgloss.Top,
lipgloss.NewStyle().Width(calendarWidth).Render(lhs.String()),
lipgloss.NewStyle().Width(tableWidth).Render(rhs.String()),
)
2025-06-18 11:06:12 +02:00
sofar := lipgloss.Height(out)
statusMsg := strings.ReplaceAll(m.statusBarMessage, "\n", " ")
out += styleStatusBar.MarginTop(19 - sofar).Width(m.windowWidth).Render(statusMsg)
2025-06-17 16:39:51 +02:00
return out
}