summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 23:03:28 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 23:03:28 +0200
commitfd29c03621e226ed756f5abc78009825e924a545 (patch)
tree6d71cc08a1644be224e5f7bea6f0d649b5e03161
parentc0ed8269e5bb8342a4be0ab1f969129a4bd9a9f6 (diff)
Task 352: add TUI timer screen with work toggle
-rw-r--r--internal/tui/timer.go266
-rw-r--r--internal/tui/timer_test.go89
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("")
+ })
+}