From 682c04ca796c8e2224f2e3ce073c5daa6ee274cf Mon Sep 17 00:00:00 2001 From: Justin Hawkins Date: Wed, 18 Jun 2025 11:06:12 +0200 Subject: [PATCH] Make great --- mite/mite.go | 59 +++++++++++++++++ model.go | 175 ++++++++++++++++++++++++++------------------------ model_form.go | 57 ++++++++++++++++ 3 files changed, 206 insertions(+), 85 deletions(-) create mode 100644 model_form.go diff --git a/mite/mite.go b/mite/mite.go index 56c9c06..33ccce9 100644 --- a/mite/mite.go +++ b/mite/mite.go @@ -1,6 +1,7 @@ package mite import ( + "bytes" "encoding/json" "fmt" "net/http" @@ -219,6 +220,64 @@ func (a APIClient) GetTimeEntries(from, to time.Time) (TimeEntries, error) { return out, nil } +type requestAddTimeEntry struct { + RequestTimeEntryHolder struct { + DateAt string `json:"date_at"` + Minutes int `json:"minutes"` + ProjectID int `json:"project_id,omit_empty"` + ServiceID int `json:"service_id,omit_empty"` + Note string `json:"note"` + } `json:"time_entry"` +} + +func (a APIClient) AddTimeEntry(date string, minutes int, notes string, projectId, serviceId int) error { + // POST /time_entries.json + // { + // "time_entry": { + // "date_at": "2015-9-15", + // "minutes": 185, + // "service_id": 243 + // } + // } + req := requestAddTimeEntry{} + req.RequestTimeEntryHolder.DateAt = date + req.RequestTimeEntryHolder.Minutes = minutes + req.RequestTimeEntryHolder.Note = notes + req.RequestTimeEntryHolder.ProjectID = projectId + req.RequestTimeEntryHolder.ServiceID = serviceId + + err := post(a.domain, a.apiKey, "/time_entries.json", req) + return err + +} + +func post(domain, apiKey, path string, data any) error { + + b, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", baseurl(domain, path), bytes.NewBuffer(b)) + if err != nil { + return err + } + req.Header.Add("X-MiteApiKey", apiKey) + req.Header.Add("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("expected 2XX, got %d", resp.StatusCode) + } + + return nil + +} + func get(domain, apiKey, path string, data any) error { req, err := http.NewRequest("GET", baseurl(domain, path), nil) if err != nil { diff --git a/model.go b/model.go index be85a58..2083c9b 100644 --- a/model.go +++ b/model.go @@ -24,15 +24,22 @@ import ( 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 - dataFetchStatus string - focus string - debug []string - formData 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 @@ -45,7 +52,8 @@ type model struct { entries mite.TimeEntries table table.Model } - windowWidth int + statusBarMessage string + windowWidth int } func initialModel(miteDomain, miteApiKey string) model { @@ -74,12 +82,12 @@ func initialModel(miteDomain, miteApiKey string) model { m.start = calendarTime{time.Now()} m.dest = calendarTime{time.Now()} m.debug = make([]string, 5, 5) - m.focus = "cal" + m.tuiMode = MODE_CAL // m.formData.form = form m.timeData.table = tab - m.dataFetchStatus = "not fetched" + m.statusBarMessage = "Fetching data from API" return m } @@ -138,11 +146,11 @@ func (m model) buildForm() *huh.Form { return nil }), huh.NewInput(). - Key("hours"). + Key("minutes"). CharLimit(5). Validate( func(s string) error { - h, err := strconv.ParseFloat(s, 10) + h, err := strconv.ParseInt(s, 10, 64) if err != nil { return err } @@ -151,7 +159,7 @@ func (m model) buildForm() *huh.Form { } return err }). - Title("Hours"), + Title("Minutes"), ), ) return form @@ -170,19 +178,42 @@ func (m model) Init() tea.Cmd { 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", 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 + } + 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.focus == "entries" { + if m.tuiMode == MODE_TIMEENTRIES { return m.updateEntries(msg) - } else if m.focus == "cal" { + } else if m.tuiMode == MODE_CAL { return m.updateCal(msg) - } else if m.focus == "addform" { + } else if m.tuiMode == MODE_FORMENTRY { return m.updateForm(msg) } else { - panic(m.focus) + panic(m.tuiMode) } } @@ -211,29 +242,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // return m, cmd // } -func (m model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { - - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - } - } - newForm, formCmd := m.formData.form.Update(msg) - m.formData.form = newForm.(*huh.Form) - - if m.formData.form.State == huh.StateCompleted { - fmt.Printf("%#v", m.timeData.entries) - println(fmt.Sprintf(m.formData.form.GetString("client"))) - println(fmt.Sprintf(m.formData.form.GetString("service"))) - println(fmt.Sprintf(m.formData.form.GetString("project"))) - println(fmt.Sprintf(m.formData.form.GetString("description"))) - println(fmt.Sprintf(m.formData.form.GetString("hours"))) - - panic("You did it!") - } - - return m, formCmd -} +// 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) { @@ -241,13 +251,13 @@ func (m model) updateEntries(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "tab": - m.focus = "cal" + m.tuiMode = MODE_CAL m.timeData.table.Blur() return m, nil case "ctrl+c", "q": return m, tea.Quit case "a": - m.focus = "addform" + m.tuiMode = MODE_FORMENTRY m.formData.form = m.buildForm() m.formData.form.Init() return m, nil @@ -266,34 +276,23 @@ func (m model) updateCal(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.windowWidth = msg.Width - case miteDataFetchedMsg: - if msg.Error != nil { - m.dataFetchStatus = "error: " + msg.Error.Error() - } else { - m.dataFetchStatus = "fetched" - m.timeData.entries = msg.TimeEntries - m.formData.customers = msg.Customers - m.formData.services = msg.Services - m.formData.projects = msg.Projects - - // just in case there is data for the currently focused day - m.timeData.table.SetRows(m.tableDataForDate(m.dest.Time)) - } - return m, nil - // Is it a key press? case tea.KeyMsg: // Cool, what was the actual key pressed? switch msg.String() { case "a": - m.focus = "addform" - m.formData.form = m.buildForm() - m.formData.form.Init() + 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.focus = "entries" + m.tuiMode = MODE_TIMEENTRIES m.timeData.table.Focus() m.timeData.table.SetCursor(0) return m, nil @@ -304,7 +303,7 @@ func (m model) updateCal(msg tea.Msg) (tea.Model, tea.Cmd) { // time entries case "f": - m.dataFetchStatus = "fetching" + m.statusBarMessage = "Fetching data from API" return m, m.fetchMiteData() // // The "up" and "k" keys move the cursor up @@ -392,17 +391,14 @@ type miteDataFetchedMsg struct { func (m model) fetchMiteData() tea.Cmd { return func() tea.Msg { - time.Sleep(time.Second * 1) + // time.Sleep(time.Second * 5) loc := time.Local te, err1 := m.miteAPI.GetTimeEntries( - time.Date(2025, time.January, 1, 0, 0, 0, 0, loc), + time.Date(2024, time.January, 1, 0, 0, 0, 0, loc), time.Date(2025, time.December, 31, 0, 0, 0, 0, loc), ) - cst, err2 := m.miteAPI.GetCustomers() - svc, err3 := m.miteAPI.GetServices() - pjt, err4 := m.miteAPI.GetProjects() return miteDataFetchedMsg{ @@ -440,25 +436,34 @@ func (m model) View() string { lhs.WriteString(calendar(m.dest, m.start, m.dest, styles)) lhs.WriteString("nav: ←↑→↓[] (t)oday\n") - lhs.WriteString("(f)etch time data\n") - lhs.WriteString("(q)uit\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.dataFetchStatus != "fetched" { - rhs.WriteString("fetching mite data...") + if m.tuiMode == MODE_FORMENTRY { + rhs.WriteString(m.formData.form.View()) } else { - - if m.focus == "addform" { - rhs.WriteString(m.formData.form.View()) - } else { - if m.dataFetchStatus == "fetched" { - rhs.WriteString(m.timeData.table.View()) - rhs.WriteString("\n") - } else { - panic("bad fetchstatus " + m.dataFetchStatus) - } + if m.fetchedData { + rhs.WriteString(m.timeData.table.View()) + rhs.WriteString("\n") } } @@ -468,10 +473,10 @@ func (m model) View() string { lipgloss.NewStyle().Width(tableWidth).Render(rhs.String()), ) - // if len(m.debug) > 5 { - // m.debug = m.debug[len(m.debug)-5:] - // } + sofar := lipgloss.Height(out) + + out += + styleStatusBar.MarginTop(19 - sofar).Width(m.windowWidth).Render(m.statusBarMessage) - // out += "\n\n" + strings.Join(m.debug, "\n") return out } diff --git a/model_form.go b/model_form.go new file mode 100644 index 0000000..10118fd --- /dev/null +++ b/model_form.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "strconv" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +// updateForm handles processing tea.Msg's related to form processing +func (m model) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc": + m.tuiMode = MODE_CAL + return m, nil + } + } + + newForm, formCmd := m.formData.form.Update(msg) + m.formData.form = newForm.(*huh.Form) + + if m.formData.form.State == huh.StateCompleted { + // fmt.Printf("%#v", m.timeData.entries) + // clientID := m.formData.form.GetString("client") + serviceID := m.formData.form.GetString("service") + projectID := m.formData.form.GetString("project") + description := m.formData.form.GetString("description") + minutes := m.formData.form.GetString("minutes") + + minutesInt, err1 := strconv.ParseInt(minutes, 10, 64) + serviceIDInt, err2 := strconv.ParseInt(serviceID, 10, 64) + projectIDInt, err3 := strconv.ParseInt(projectID, 10, 64) + + if errors.Join(err1, err2, err3) != nil { + m.statusBarMessage = errors.Join(err1, err2, err3).Error() + m.tuiMode = MODE_CAL + } else { + + err := m.miteAPI.AddTimeEntry(m.dest.Format(time.DateOnly), int(minutesInt), description, int(projectIDInt), int(serviceIDInt)) + if err != nil { + m.statusBarMessage = errors.Join(err1, err2, err3).Error() + m.tuiMode = MODE_CAL + } else { + m.statusBarMessage = "Successfully logged time" + m.tuiMode = MODE_CAL + } + } + + } + + return m, formCmd +}