From 77d11c4351a59978b82ccad659914afa641b56b2 Mon Sep 17 00:00:00 2001 From: Justin Hawkins Date: Tue, 17 Jun 2025 16:39:51 +0200 Subject: [PATCH] First checkin --- build.sh | 10 ++ calendar.go | 166 ++++++++++++++++++ go.mod | 43 +++++ go.sum | 85 +++++++++ main.go | 46 +++++ mite/mite.go | 247 ++++++++++++++++++++++++++ model.go | 477 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1074 insertions(+) create mode 100755 build.sh create mode 100644 calendar.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 mite/mite.go create mode 100644 model.go diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..3c6ee0a --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +GOOS=linux GOARCH=amd64 go build -o cal_linux_amd64 +GOOS=darwin GOARCH=arm64 go build -o cal_darwin_arm64 +GOOS=windows GOARCH=amd64 go build -o cal_windows_lol_amd64 + +zip cal.zip cal_linux_amd64 cal_darwin_arm64 cal_windows_lol_amd64 + + +open . diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..688360f --- /dev/null +++ b/calendar.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/dustin/go-humanize" +) + +type calendarTime struct { + time.Time +} + +// 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 dayCols = map[int]time.Weekday{ + 0: time.Monday, + 1: time.Tuesday, + 2: time.Wednesday, + 3: time.Thursday, + 4: time.Friday, + 5: time.Saturday, + 6: time.Sunday, +} + +const brailleStart = 0x2800 +const brailleEnd = 0x28FF + +var baseStyle = lipgloss.NewStyle().Width(7 * 3) + +var weekdayStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#2222f2")) +var weekendStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0022")) +var monthNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00dd80")) +var yearStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#a0dd10")) +var calendarTitle = baseStyle.AlignHorizontal(lipgloss.Center) +var dayStyle = lipgloss.NewStyle() +var dayStartStyle = dayStyle.Background(lipgloss.Color("#555555")) +var highlightStyle = dayStyle.Foreground(lipgloss.Color("#000000")).Background(lipgloss.Color("#ffffff")).Blink(true) + +// calendar takes three calendarTime's, the calendar month to show, the "start" calendar time +// (shown with a underline) and the highlighted calendarTime. +func calendar(t calendarTime, start calendarTime, hl calendarTime, styles map[string]lipgloss.Style) string { + f, _ := t.firstLastDays() + out := calendarTitle.Render(fmt.Sprintf("%s %s", monthNameStyle.Render(f.Month().String()), yearStyle.Render(fmt.Sprint(f.Year())))) + out += "\n" + out += weekdayStyle.Render(" Mo Tu We Th Fr ") + out += weekendStyle.Render("Sa Su") + out += "\n" + + at := f + col := 0 + lines := 0 + for { + if at.Weekday() == dayCols[col] { + + // codePoint := brailleStart + rand.Intn(brailleEnd-brailleStart+1) + // brailleChar := rune(codePoint) + + // if rand.Intn(100) < 80 { + // brailleChar = ' ' + // } + // pre := lipgloss.NewStyle().Foreground(lipgloss.Color("#d00010")).Render(string(brailleChar)) + + dayStyle := dayStyle + specialStyle, ok := styles[at.Format(time.DateOnly)] + if ok { + dayStyle = specialStyle + } + + dayString := fmt.Sprintf("%2d", at.Day()) + padding := 3 - len(fmt.Sprint(at.Day())) + padding = 1 + out += strings.Repeat(" ", padding) + + if at.dayEqual(hl) { + out += highlightStyle.Render(dayString) + } else if at.dayEqual(start) { + out += dayStartStyle.Render(dayString) + } else { + out += dayStyle.Render(dayString) + } + } else { + col++ + out += " " + continue + } + + at = calendarTime{at.AddDate(0, 0, 1)} + if at.Month() != f.Month() { + break + } + + col++ + if col > 6 { + out += "\n" + col = 0 + lines++ + } + } + + for lines < 6 { + out += "\n" + lines++ + } + out += "\n" + return out + +} + +func (ct calendarTime) dayEqual(t calendarTime) bool { + y1, m1, d1 := ct.Date() + y2, m2, d2 := t.Date() + if y1 == y2 && m1 == m2 && d1 == d2 { + return true + } + return false +} + +func (ct calendarTime) firstLastDays() (calendarTime, calendarTime) { + loc := ct.Location() + y, m, _ := ct.Date() + first := time.Date(y, m, 1, 0, 0, 0, 0, loc) + m++ + if m > 12 { + y++ + } + last := time.Date(y, m, 1, 0, 0, 0, 0, loc).AddDate(0, 0, -1) + return calendarTime{first}, calendarTime{last} +} + +func (ct calendarTime) addDay() calendarTime { + return calendarTime{ct.AddDate(0, 0, 1)} +} +func (ct calendarTime) subDay() calendarTime { + return calendarTime{ct.AddDate(0, 0, -1)} +} + +func (ct calendarTime) addWeek() calendarTime { + return calendarTime{ct.AddDate(0, 0, 7)} +} +func (ct calendarTime) subWeek() calendarTime { + return calendarTime{ct.AddDate(0, 0, -7)} +} +func (ct calendarTime) addMonth() calendarTime { + return calendarTime{ct.AddDate(0, 1, 0)} +} +func (ct calendarTime) subMonth() calendarTime { + return calendarTime{ct.AddDate(0, -1, 0)} +} + +func (ct calendarTime) to(t calendarTime) string { + from := t.Time + to := ct.Time + rel := humanize.RelTime(t.Time, ct.Time, "earlier", "later") + days := int(from.Sub(to).Hours() / 24) + + return fmt.Sprintf("%s / %d days", rel, days) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ea3fb57 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module charmcal + +go 1.24.1 + +require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.23.0 // indirect +) + +require ( + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/huh v0.7.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fdb24a6 --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= +github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..adfb91a --- /dev/null +++ b/main.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +// 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 + +func main() { + // from, _ := time.Parse(time.DateOnly, "2025-03-01") + // to, _ := time.Parse(time.DateOnly, "2025-05-27") + + // pr, _ := mite.GetTimeEntries(from, to) + + // for i := range pr { + // fmt.Printf("%s\n", pr[i].String()) + // } + // os.Exit(0) + miteDomain := os.Getenv("MITE_DOMAIN") + miteApiKey := os.Getenv("MITE_APIKEY") + + if miteDomain == "" { + fmt.Println("You must set MITE_DOMAIN") + os.Exit(1) + } + if miteApiKey == "" { + fmt.Println("You must set MITE_APIKEY") + os.Exit(1) + } + + p := tea.NewProgram(initialModel(miteDomain, miteApiKey)) + if _, err := p.Run(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } + +} diff --git a/mite/mite.go b/mite/mite.go new file mode 100644 index 0000000..56c9c06 --- /dev/null +++ b/mite/mite.go @@ -0,0 +1,247 @@ +package mite + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +type APIClient struct { + domain string + apiKey string +} + +func NewClient(domain, apikey string) APIClient { + return APIClient{ + domain: domain, + apiKey: apikey, + } +} + +type APIObject interface { + GetID() int + GetName() string +} + +// https://demo.mite.de/ +//curl -H 'X-MiteApiKey: YOUR_API_KEY' https://demo.mite.de/projects.json + +type APIProjects []ProjectHolder +type ProjectHolder struct { + Project Project `json:"project"` +} +type Projects []Project +type Project struct { + Budget int `json:"budget"` + BudgetType string `json:"budget_type"` + CreatedAt time.Time `json:"created_at"` + CustomerID int `json:"customer_id"` + HourlyRate int `json:"hourly_rate"` + ID int `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + UpdatedAt time.Time `json:"updated_at"` + Archived bool `json:"archived"` + CustomerName string `json:"customer_name"` +} + +func (p Project) String() string { + return fmt.Sprintf("%d: %s (%s)", p.ID, p.Name, p.CustomerName) +} +func (p Project) GetID() int { return p.ID } +func (p Project) GetName() string { return p.Name } + +func (a APIClient) GetProjects() (Projects, error) { + // GET /projects.json + p := APIProjects{} + err := get(a.domain, a.apiKey, "/projects.json", &p) + if err != nil { + return nil, err + } + + out := Projects{} + for i := range p { + out = append(out, p[i].Project) + } + return out, nil +} + +type apiCustomers []customerHolder +type customerHolder struct { + Customer Customer `json:"customer"` +} + +type Customers []Customer +type Customer struct { + CreatedAt time.Time `json:"created_at"` + HourlyRate int `json:"hourly_rate"` + ID int `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + UpdatedAt time.Time `json:"updated_at"` + Archived bool `json:"archived"` + ActiveHourlyRate string `json:"active_hourly_rate"` + HourlyRatesPerService []struct { + ServiceID int `json:"service_id"` + HourlyRate int `json:"hourly_rate"` + } `json:"hourly_rates_per_service"` +} + +func (c Customer) String() string { + return fmt.Sprintf("%d: %s", c.ID, c.Name) +} +func (c Customer) GetID() int { return c.ID } +func (c Customer) GetName() string { return c.Name } + +func (a APIClient) GetCustomers() (Customers, error) { + // GET /customers.json + p := apiCustomers{} + err := get(a.domain, a.apiKey, "/customers.json", &p) + if err != nil { + return nil, err + } + + out := Customers{} + for i := range p { + out = append(out, p[i].Customer) + } + return out, nil +} + +type apiServices []serviceHolder +type serviceHolder struct { + Service Service `json:"service"` +} +type Services []Service +type Service struct { + Billable bool `json:"billable"` + CreatedAt time.Time `json:"created_at"` + HourlyRate int `json:"hourly_rate"` + ID int `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + UpdatedAt time.Time `json:"updated_at"` + Archived bool `json:"archived"` +} + +func (s Service) String() string { + return fmt.Sprintf("%d: %s", s.ID, s.Name) +} +func (s Service) GetID() int { return s.ID } +func (s Service) GetName() string { return s.Name } + +func (a APIClient) GetServices() (Services, error) { + // GET /services.json + p := apiServices{} + err := get(a.domain, a.apiKey, "/services.json", &p) + if err != nil { + return nil, err + } + + out := Services{} + for i := range p { + out = append(out, p[i].Service) + } + return out, nil +} + +type apiTimeEntry []timeEntryHolder +type timeEntryHolder struct { + TimeEntry TimeEntry `json:"time_entry"` +} +type TimeEntries []TimeEntry +type TimeEntry struct { + Billable bool `json:"billable"` + CreatedAt time.Time `json:"created_at"` + DateAt string `json:"date_at"` + ID int `json:"id"` + Locked bool `json:"locked"` + Minutes int `json:"minutes"` + StartedTime interface{} `json:"started_time"` + ProjectID int `json:"project_id"` + Revenue float64 `json:"revenue"` + HourlyRate int `json:"hourly_rate"` + ServiceID int `json:"service_id"` + UpdatedAt time.Time `json:"updated_at"` + UserID int `json:"user_id"` + Note string `json:"note"` + UserName string `json:"user_name"` + CustomerID int `json:"customer_id"` + CustomerName string `json:"customer_name"` + ProjectName string `json:"project_name"` + ServiceName string `json:"service_name"` +} + +func (t TimeEntry) String() string { + return fmt.Sprintf("%d: %s - %s for %dm", t.ID, t.DateAt, t.CustomerName, t.Minutes) +} + +func (te TimeEntries) ByDate(t time.Time) TimeEntries { + ymd := t.Format(time.DateOnly) + out := TimeEntries{} + for i := range te { + if te[i].DateAt == ymd { + out = append(out, te[i]) + } + } + return out +} + +func (te TimeEntries) MinutesByDate() map[string]int { + out := map[string]int{} + for i := range te { + dt := te[i].DateAt + if _, ok := out[dt]; ok { + out[dt] += te[i].Minutes + } else { + out[dt] = te[i].Minutes + } + } + return out +} + +// GetTimeEntries the "to" time is inclusive - ie time entries on that date are included +func (a APIClient) GetTimeEntries(from, to time.Time) (TimeEntries, error) { + // GET /... YYYY-MM-DD from= to= + p := apiTimeEntry{} + u := fmt.Sprintf("/time_entries.json?from=%s&to=%s", from.Format(time.DateOnly), to.Format(time.DateOnly)) + + err := get(a.domain, a.apiKey, u, &p) + if err != nil { + return nil, err + } + + out := TimeEntries{} + for i := range p { + out = append(out, p[i].TimeEntry) + } + return out, nil +} + +func get(domain, apiKey, path string, data any) error { + req, err := http.NewRequest("GET", baseurl(domain, path), nil) + if err != nil { + return err + } + req.Header.Add("X-MiteApiKey", apiKey) + 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) + } + err = json.NewDecoder(resp.Body).Decode(&data) + if err != nil { + return err + } + return nil + +} + +func baseurl(domain, path string) string { + return fmt.Sprintf("https://%s.mite.de/%s", domain, path) +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..be85a58 --- /dev/null +++ b/model.go @@ -0,0 +1,477 @@ +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 +}