First checkin

This commit is contained in:
Justin Hawkins 2025-06-17 16:39:51 +02:00
commit 77d11c4351
7 changed files with 1074 additions and 0 deletions

10
build.sh Executable file
View File

@ -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 .

166
calendar.go Normal file
View File

@ -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)
}

43
go.mod Normal file
View File

@ -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
)

85
go.sum Normal file
View File

@ -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=

46
main.go Normal file
View File

@ -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)
}
}

247
mite/mite.go Normal file
View File

@ -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)
}

477
model.go Normal file
View File

@ -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
}