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 type model struct { miteAPI mite.APIClient start calendarTime dest calendarTime dataFetchStatus string focus string 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 } windowWidth 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.focus = "cal" // m.formData.form = form m.timeData.table = tab m.dataFetchStatus = "not fetched" 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(). Key("hours"). CharLimit(5). Validate( func(s string) error { h, err := strconv.ParseFloat(s, 10) if err != nil { return err } if h < 0 { return errors.New("must be positive") } return err }). Title("Hours"), ), ) 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) { 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" { return m.updateEntries(msg) } else if m.focus == "cal" { return m.updateCal(msg) } else if m.focus == "addform" { return m.updateForm(msg) } else { panic(m.focus) } } // 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 // } 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 } func (m model) updateEntries(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "tab": m.focus = "cal" m.timeData.table.Blur() return m, nil case "ctrl+c", "q": return m, tea.Quit case "a": m.focus = "addform" 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 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() return m, nil case "tab": m.focus = "entries" 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.dataFetchStatus = "fetching" 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 { time.Sleep(time.Second * 1) loc := time.Local te, err1 := m.miteAPI.GetTimeEntries( time.Date(2025, 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{ 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") lhs.WriteString("(f)etch time data\n") lhs.WriteString("(q)uit\n") calendarWidth := 25 tableWidth := m.windowWidth - calendarWidth if m.dataFetchStatus != "fetched" { rhs.WriteString("fetching mite data...") } 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) } } } out := lipgloss.JoinHorizontal( lipgloss.Top, lipgloss.NewStyle().Width(calendarWidth).Render(lhs.String()), lipgloss.NewStyle().Width(tableWidth).Render(rhs.String()), ) // if len(m.debug) > 5 { // m.debug = m.debug[len(m.debug)-5:] // } // out += "\n\n" + strings.Join(m.debug, "\n") return out }