409 lines
10 KiB
Go
409 lines
10 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"
|
|
|
|
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
|
|
}
|
|
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{
|
|
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)
|
|
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.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.Sprint(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
|
|
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()
|
|
|
|
return miteDataFetchedMsg{
|
|
TimeEntries: te,
|
|
Customers: cst,
|
|
Services: svc,
|
|
Projects: pjt,
|
|
Error: errors.Join(err1, err2, err3, err4),
|
|
start: t0,
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
|
|
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")
|
|
}
|
|
|
|
calendarWidth := 25
|
|
tableWidth := m.windowWidth - calendarWidth
|
|
|
|
if m.tuiMode == MODE_FORMENTRY {
|
|
rhs.WriteString(m.formData.form.View())
|
|
} else {
|
|
if m.fetchedData {
|
|
rhs.WriteString(m.timeData.table.View())
|
|
rhs.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
out := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
lipgloss.NewStyle().Width(calendarWidth).Render(lhs.String()),
|
|
lipgloss.NewStyle().Width(tableWidth).Render(rhs.String()),
|
|
)
|
|
|
|
sofar := lipgloss.Height(out)
|
|
|
|
statusMsg := strings.ReplaceAll(m.statusBarMessage, "\n", " ")
|
|
out += styleStatusBar.MarginTop(m.windowHeight - sofar).Width(m.windowWidth).Render(statusMsg)
|
|
|
|
return out
|
|
}
|