diff options
| -rw-r--r-- | internal/tui/timer.go | 266 | ||||
| -rw-r--r-- | internal/tui/timer_test.go | 89 |
2 files changed, 355 insertions, 0 deletions
diff --git a/internal/tui/timer.go b/internal/tui/timer.go new file mode 100644 index 0000000..4a3ce02 --- /dev/null +++ b/internal/tui/timer.go @@ -0,0 +1,266 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "codeberg.org/snonux/timr/internal/ascii" + "codeberg.org/snonux/timr/internal/config" + timrTimer "codeberg.org/snonux/timr/internal/timer" + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/common-nighthawk/go-figure" +) + +type timerTickMsg time.Time + +func timerTick() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return timerTickMsg(t) + }) +} + +// TimerModel is the timer screen model used inside the TUI. +type TimerModel struct { + state timrTimer.State + quitting bool + helpStyle lipgloss.Style + timerStyle lipgloss.Style + statusStyle lipgloss.Style + width int + height int + font string + fontIndex int + + workEnabled bool + workDBDir string + workHost string + workLoggedIn bool + workStatus string +} + +// NewTimerModel builds the timer screen model. +func NewTimerModel(font string, cfg config.Config) (TimerModel, error) { + state, err := timrTimer.LoadState() + if err != nil { + return TimerModel{}, err + } + + selectedFont := strings.TrimSpace(font) + if selectedFont == "" { + selectedFont = "doom" + } + + fontIndex := 0 + for idx, available := range ascii.AllFonts { + if available == selectedFont { + fontIndex = idx + break + } + } + + model := TimerModel{ + state: state, + helpStyle: lipgloss.NewStyle().Faint(true), + timerStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF")), + statusStyle: lipgloss.NewStyle().Italic(true), + font: selectedFont, + fontIndex: fontIndex, + workStatus: "work integration disabled", + } + + if strings.TrimSpace(cfg.WorktimeDBDir) != "" { + host, err := cfg.EffectiveHostname() + if err == nil { + model.workEnabled = true + model.workDBDir = cfg.WorktimeDBDir + model.workHost = host + model.refreshWorkStatus() + } + } + + return model, nil +} + +// Init is called when the model starts. +func (m TimerModel) Init() tea.Cmd { + if m.state.Running { + return timerTick() + } + return nil +} + +// Update handles timer screen updates. +func (m TimerModel) Update(msg tea.Msg) (TimerModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case timerTickMsg: + if !m.state.Running { + return m, nil + } + return m, timerTick() + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + _ = m.state.Save() + return m, tea.Quit + + case "s", " ": + if m.state.Running { + m.state.ElapsedTime += time.Since(m.state.StartTime) + m.state.Running = false + } else { + m.state.Running = true + m.state.StartTime = time.Now() + if err := m.state.Save(); err != nil { + m.workStatus = "save error: " + err.Error() + } + return m, timerTick() + } + if err := m.state.Save(); err != nil { + m.workStatus = "save error: " + err.Error() + } + return m, nil + + case "r": + m.state = timrTimer.State{} + if _, err := timrTimer.ResetTimer(); err != nil { + m.workStatus = "reset error: " + err.Error() + } + return m, nil + + case "f": + m.fontIndex = (m.fontIndex + 1) % len(ascii.AllFonts) + m.font = ascii.AllFonts[m.fontIndex] + return m, nil + + case "l": + m.toggleWorkLogin() + return m, nil + } + } + + return m, nil +} + +// View renders the timer screen. +func (m TimerModel) View() string { + if m.quitting { + return "" + } + + elapsed := m.state.ElapsedTime + if m.state.Running { + elapsed += time.Since(m.state.StartTime) + } + elapsed = elapsed.Round(time.Second) + + timerOutput := m.renderTimer(elapsed) + + status := "Paused" + if m.state.Running { + status = "Running" + } + + lines := []string{ + timerOutput, + m.statusStyle.Render(status), + "", + m.helpStyle.Render(fmt.Sprintf("Font: %s", m.font)), + m.helpStyle.Render(fmt.Sprintf("Work: %s", m.workStatus)), + m.helpStyle.Render("s/Space: start-stop, r: reset, f: change font, l: work login/logout, q: quit"), + } + + return lipgloss.Place( + m.width, + m.height, + lipgloss.Center, + lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, lines...), + ) +} + +func (m TimerModel) renderTimer(elapsed time.Duration) string { + if m.font == "doom" { + figureOutput := figure.NewFigure(elapsed.String(), "doom", true) + return m.timerStyle.Render(figureOutput.String()) + } + + font := ascii.GetFont(m.font) + hours := int(elapsed.Hours()) + minutes := int(elapsed.Minutes()) % 60 + seconds := int(elapsed.Seconds()) % 60 + timeString := fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) + asciiTime := ascii.RenderNumber(timeString, font) + return m.timerStyle.Render(asciiTime) +} + +func (m *TimerModel) toggleWorkLogin() { + if !m.workEnabled { + m.workStatus = "work integration disabled" + return + } + + now := time.Now() + var err error + if m.workLoggedIn { + _, err = worktime.Logout(m.workDBDir, m.workHost, "work", now, "tui timer toggle") + } else { + _, err = worktime.Login(m.workDBDir, m.workHost, "work", now, "tui timer toggle") + } + if err != nil { + m.workStatus = "work toggle error: " + err.Error() + return + } + + m.refreshWorkStatus() +} + +func (m *TimerModel) refreshWorkStatus() { + if !m.workEnabled { + m.workStatus = "work integration disabled" + return + } + + entries, err := worktime.LoadAll(m.workDBDir) + if err != nil { + m.workStatus = "work status error: " + err.Error() + return + } + + m.workLoggedIn = workCategoryLoggedIn(entries) + if m.workLoggedIn { + m.workStatus = "logged in" + } else { + m.workStatus = "logged out" + } +} + +func workCategoryLoggedIn(entries []worktime.Entry) bool { + loggedIn := false + for _, entry := range entries { + category := strings.TrimSpace(entry.What) + if category == "" { + category = "work" + } + if category != "work" { + continue + } + + switch strings.ToLower(strings.TrimSpace(entry.Action)) { + case "login": + loggedIn = true + case "logout": + loggedIn = false + } + } + return loggedIn +} diff --git a/internal/tui/timer_test.go b/internal/tui/timer_test.go new file mode 100644 index 0000000..5c6ac51 --- /dev/null +++ b/internal/tui/timer_test.go @@ -0,0 +1,89 @@ +package tui + +import ( + "path/filepath" + "testing" + + "codeberg.org/snonux/timr/internal/config" + timrTimer "codeberg.org/snonux/timr/internal/timer" + "codeberg.org/snonux/timr/internal/worktime" + tea "github.com/charmbracelet/bubbletea" +) + +func TestTimerModelToggleWorkLogin(t *testing.T) { + setupTimerStateForTUI(t) + + dbDir := t.TempDir() + cfg := config.Default() + cfg.WorktimeDBDir = dbDir + cfg.Hostname = "host-a" + + model, err := NewTimerModel("doom", cfg) + if err != nil { + t.Fatalf("NewTimerModel() error = %v", err) + } + + if !model.workEnabled { + t.Fatal("work integration should be enabled") + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if !model.workLoggedIn { + t.Fatal("work should be logged in after first l") + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if model.workLoggedIn { + t.Fatal("work should be logged out after second l") + } + + entries, err := worktime.LoadAll(dbDir) + if err != nil { + t.Fatalf("LoadAll() error = %v", err) + } + if len(entries) < 2 { + t.Fatalf("entries len = %d, want at least 2", len(entries)) + } +} + +func TestTimerModelFontCycling(t *testing.T) { + setupTimerStateForTUI(t) + + model, err := NewTimerModel("doom", config.Default()) + if err != nil { + t.Fatalf("NewTimerModel() error = %v", err) + } + + originalFont := model.font + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + if model.font == originalFont { + t.Fatalf("font did not change after f: %q", model.font) + } +} + +func TestTimerModelWorkToggleWhenDisabled(t *testing.T) { + setupTimerStateForTUI(t) + + cfg := config.Default() + cfg.WorktimeDBDir = "" + + model, err := NewTimerModel("doom", cfg) + if err != nil { + t.Fatalf("NewTimerModel() error = %v", err) + } + + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + if model.workStatus != "work integration disabled" { + t.Fatalf("workStatus = %q, want work integration disabled", model.workStatus) + } +} + +func setupTimerStateForTUI(t *testing.T) { + t.Helper() + + tempDir := t.TempDir() + timrTimer.SetStateFilePathOverride(filepath.Join(tempDir, ".timr_state")) + t.Cleanup(func() { + timrTimer.SetStateFilePathOverride("") + }) +} |
