charmite/model.go
Justin Hawkins 4eaf1e73bb
All checks were successful
CI / test (push) Successful in 40s
Show the current tracker time if it is active.
2025-06-20 19:13:48 +02:00

452 lines
11 KiB
Go

package main
import (
"charmcal/mite"
"errors"
"fmt"
"slices"
"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
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"
var version = "dev"
type model struct {
miteAPI mite.APIClient
start calendarTime
dest calendarTime
fetchedData bool // done initial fetch to get customers/projects etc
tuiMode tuiMode //
debug []string
formData struct {
form *huh.Form
customers mite.Customers
services mite.Services
projects mite.Projects
selected struct {
customer string
}
}
timeData struct {
entries mite.TimeEntries
table table.Model
tracker *mite.TrackingTimeEntry
}
statusBarMessage string
windowWidth int
windowHeight int
}
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{
{Title: " min", Width: 4},
{Title: "🔐", Width: 2},
{Title: "customer", Width: 10},
{Title: "description", Width: 40},
})
m.start = calendarTime{time.Now()}
m.dest = calendarTime{time.Now()}
m.debug = make([]string, 5, 5)
m.tuiMode = MODE_CAL
// m.formData.form = form
m.timeData.table = tab
m.statusBarMessage = "Fetching data from API"
return m
}
func (m model) Init() tea.Cmd {
return m.fetchMiteData()
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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 in %s", len(msg.TimeEntries), time.Since(msg.start).Truncate(time.Millisecond))
m.timeData.entries = msg.TimeEntries
m.formData.customers = msg.Customers
m.formData.services = msg.Services
m.formData.projects = msg.Projects
m.timeData.tracker = msg.Tracker
m.fetchedData = true
slices.SortFunc(m.formData.customers, func(a, b mite.Customer) int { return strings.Compare(a.GetName(), b.GetName()) })
slices.SortFunc(m.formData.services, func(a, b mite.Service) int { return strings.Compare(a.GetName(), b.GetName()) })
slices.SortFunc(m.formData.projects, func(a, b mite.Project) int { return strings.Compare(a.GetName(), b.GetName()) })
// just in case there is data for the currently focused day
m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time))
}
return m, nil
}
if len(m.debug) > 5 {
m.debug = m.debug[len(m.debug)-5:]
}
m.debug = append(m.debug, fmt.Sprintf("Got a %#v", msg))
if m.tuiMode == MODE_TIMEENTRIES {
return m.updateEntries(msg)
} else if m.tuiMode == MODE_CAL {
return m.updateCal(msg)
} else if m.tuiMode == MODE_FORMENTRY {
return m.updateForm(msg)
} else {
panic(m.tuiMode)
}
}
// 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
// }
// updateEntries handles processing tea messages when the days time entries are being
// processed
func (m model) updateEntries(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "tab":
m.tuiMode = MODE_CAL
m.timeData.table.Blur()
return m, nil
case "ctrl+c", "q":
return m, tea.Quit
case "a":
m.tuiMode = MODE_FORMENTRY
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
m.windowHeight = msg.Height
// Is it a key press?
case tea.KeyMsg:
// Cool, what was the actual key pressed?
switch msg.String() {
case "a":
if m.fetchedData {
m.tuiMode = MODE_FORMENTRY
m.formData.form = m.buildForm()
m.formData.form.Init()
} else {
m.statusBarMessage = "Not yet fetched data"
}
return m, nil
case "tab":
m.tuiMode = MODE_TIMEENTRIES
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":
m.statusBarMessage = "Fetching data from API"
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.Sprintf("%4d", entry.Minutes), lock, entry.CustomerName, entry.Note})
}
return rows
}
type miteDataFetchedMsg struct {
start time.Time
TimeEntries mite.TimeEntries
Customers mite.Customers
Services mite.Services
Projects mite.Projects
Tracker *mite.TrackingTimeEntry
Error error
}
func (m model) fetchMiteData() tea.Cmd {
return func() tea.Msg {
t0 := time.Now()
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)
cst, err2 := m.miteAPI.GetCustomers()
svc, err3 := m.miteAPI.GetServices()
pjt, err4 := m.miteAPI.GetProjects()
tt, err5 := m.miteAPI.GetTimeTracker()
var msgTT = &tt
if err5 == mite.ErrNoTracker {
err5 = nil
msgTT = nil
}
return miteDataFetchedMsg{
TimeEntries: te,
Customers: cst,
Services: svc,
Projects: pjt,
Tracker: msgTT,
Error: errors.Join(err1, err2, err3, err4),
start: t0,
}
}
}
var subs = [11]rune{'₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉', '₊'}
func (m model) View() string {
lhs := strings.Builder{}
rhs := strings.Builder{}
nb := lipgloss.NewStyle().Border(lipgloss.NormalBorder())
db := lipgloss.NewStyle().Border(lipgloss.DoubleBorder())
lhsS := nb
rhsS := nb
// 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")
if m.tuiMode == MODE_CAL {
lhs.WriteString("(f)etch time data\n")
lhsS = db
} 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")
rhsS = db
} else {
lhs.WriteString("(tab) switch panes\n")
lhs.WriteString("(q)uit\n")
}
if m.timeData.tracker != nil {
activeTime := time.Since(m.timeData.tracker.Since).Truncate(time.Minute).String()
activeTime = strings.Replace(activeTime, "0s", "", 1)
lhs.WriteString("\nTracker active: " + activeTime + "\n")
}
if m.tuiMode == MODE_TIMEENTRIES {
rhsS = db
}
lhsWidth := 25
rhsWidth := m.windowWidth - lhsWidth - 4
if m.tuiMode == MODE_FORMENTRY {
rhs.WriteString(m.formData.form.View())
} else {
if m.fetchedData {
m.timeData.table.Columns()[3].Width = rhsWidth - 30
m.timeData.table.SetHeight(14)
rhs.WriteString(m.timeData.table.View())
rhs.WriteString("\n")
}
}
out := lipgloss.JoinHorizontal(
lipgloss.Top,
lhsS.Render(lipgloss.NewStyle().Width(lhsWidth).Render(lhs.String())),
rhsS.Render(lipgloss.NewStyle().Width(rhsWidth).Render(rhs.String())),
)
sofar := lipgloss.Height(out)
statusMsg := strings.ReplaceAll(m.statusBarMessage, "\n", " ")
mainMsg := styleStatusBar.Width(m.windowWidth - len(version)).MarginTop(m.windowHeight - sofar).Render(statusMsg)
versionMsg := styleStatusBar.MarginTop(m.windowHeight - sofar).Render(version)
bar := lipgloss.JoinHorizontal(lipgloss.Top,
mainMsg,
versionMsg,
)
out += bar
return out
}