First checkin
This commit is contained in:
commit
77d11c4351
10
build.sh
Executable file
10
build.sh
Executable 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
166
calendar.go
Normal 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
43
go.mod
Normal 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
85
go.sum
Normal 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
46
main.go
Normal 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
247
mite/mite.go
Normal 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
477
model.go
Normal 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
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user