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 tickMsg time.Time func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } type model struct { currentTime time.Time 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 tea.Batch( m.fetchMiteData(), tick(), ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // things we process no matter the tuiMode switch msg := msg.(type) { case tickMsg: m.currentTime = time.Time(msg) return m, tick() // schedule next tick 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)) switch m.tuiMode { case MODE_TIMEENTRIES: return m.updateEntries(msg) case MODE_CAL: return m.updateCal(msg) case MODE_FORMENTRY: return m.updateForm(msg) default: 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 case "c": if m.timeData.tracker != nil { m, msg := m.cancelTracker() return m, msg } } } newTable, tableCmd := m.timeData.table.Update(msg) m.timeData.table = newTable return m, tableCmd } func (m model) cancelTracker() (model, tea.Cmd) { stopped, err := m.miteAPI.StopTimeTracker(m.timeData.tracker.ID) if err != nil { m.statusBarMessage = err.Error() return m, nil } else { m.statusBarMessage = fmt.Sprintf("Stopped tracking: %d", stopped.ID) m.timeData.tracker = nil return m, m.fetchMiteData() } } 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() // cancel tracker case "c": if m.timeData.tracker != nil { return m.cancelTracker() } // // 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 space bar (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.globalKeyMode() { 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.Second).String() activeTime = strings.Replace(activeTime, "0ms", "", 1) lhs.WriteString("\nTracker active: " + activeTime + "\n") if m.globalKeyMode() { lhs.WriteString("(c)ancel\n") } else { lhs.WriteString("\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 } // globalKeyMode indicates when the UI can receive most keystrokes for global operations - // mostly this means the form is not active func (m model) globalKeyMode() bool { return m.tuiMode != MODE_FORMENTRY }