summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 23:42:04 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 23:42:04 +0200
commit293406c3bd2acc85490da11afabaf6733babd5e4 (patch)
tree1d3df45b9fb4511c37feaeacc885e1913e7f8ed6
parent2c414a53dc95c75584398246beb34f7ddf181448 (diff)
tui: wire real entries/report data and improve entry editing
Includes task 354 error-wrapping updates in cli/timer.go.
-rw-r--r--internal/cli/timer.go16
-rw-r--r--internal/cli/tui.go5
-rw-r--r--internal/tui/entries.go189
-rw-r--r--internal/tui/entries_test.go60
-rw-r--r--internal/tui/timer.go6
-rw-r--r--internal/tui/tui.go157
-rw-r--r--internal/tui/tui_test.go40
-rw-r--r--internal/worktime/db.go59
-rw-r--r--internal/worktime/db_test.go52
9 files changed, 536 insertions, 48 deletions
diff --git a/internal/cli/timer.go b/internal/cli/timer.go
index 6c344e5..78d6d8d 100644
--- a/internal/cli/timer.go
+++ b/internal/cli/timer.go
@@ -41,11 +41,11 @@ func newTimerStartCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
rawStatus, err := timrTimer.GetRawStatus()
if err != nil {
- return err
+ return fmt.Errorf("get raw timer status: %w", err)
}
status, err := strconv.ParseFloat(rawStatus, 64)
if err != nil {
- return err
+ return fmt.Errorf("parse raw timer status %q: %w", rawStatus, err)
}
output, err := timrTimer.StartTimer(status > 0)
@@ -67,7 +67,7 @@ func newTimerStopCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
output, err := timrTimer.StopTimer()
if err != nil {
- return err
+ return fmt.Errorf("stop timer: %w", err)
}
if err := syncWorktimeWithTimer(false); err != nil {
return err
@@ -84,11 +84,11 @@ func newTimerContinueCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
rawStatus, err := timrTimer.GetRawStatus()
if err != nil {
- return err
+ return fmt.Errorf("get raw timer status: %w", err)
}
status, err := strconv.ParseFloat(rawStatus, 64)
if err != nil {
- return err
+ return fmt.Errorf("parse raw timer status %q: %w", rawStatus, err)
}
output := "Timer is at 0, cannot continue."
@@ -111,7 +111,7 @@ func newTimerResetCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
output, err := timrTimer.ResetTimer()
if err != nil {
- return err
+ return fmt.Errorf("reset timer: %w", err)
}
return printOutput(cmd, output)
},
@@ -163,7 +163,7 @@ func newTimerPromptCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
output, err := timrTimer.GetPromptStatus()
if err != nil {
- return err
+ return fmt.Errorf("get prompt timer status: %w", err)
}
return printOutput(cmd, output)
},
@@ -179,7 +179,7 @@ func newTimerTrackCmd() *cobra.Command {
description := strings.Join(args, " ")
output, err := timrTimer.TrackTime(description)
if err != nil {
- return err
+ return fmt.Errorf("track timer entry %q: %w", description, err)
}
return printOutput(cmd, output)
},
diff --git a/internal/cli/tui.go b/internal/cli/tui.go
index 1a8ea21..c65b988 100644
--- a/internal/cli/tui.go
+++ b/internal/cli/tui.go
@@ -11,7 +11,10 @@ func newTUICmd() *cobra.Command {
Use: "tui",
Short: "Launch full-screen TUI",
RunE: func(cmd *cobra.Command, args []string) error {
- model := tuiapp.NewModel()
+ model, err := tuiapp.NewModelWithConfig(CurrentConfig())
+ if err != nil {
+ return err
+ }
program := tea.NewProgram(model)
return program.Start()
},
diff --git a/internal/tui/entries.go b/internal/tui/entries.go
index 635f903..2a10d21 100644
--- a/internal/tui/entries.go
+++ b/internal/tui/entries.go
@@ -1,16 +1,26 @@
package tui
import (
+ "errors"
"fmt"
"slices"
+ "strconv"
"strings"
"time"
+ "codeberg.org/snonux/timr/internal/duration"
"codeberg.org/snonux/timr/internal/worktime"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
+type entryEditField int
+
+const (
+ entryEditFieldDescription entryEditField = iota
+ entryEditFieldValue
+)
+
// EntriesModel is a chronological worktime entry browser.
type EntriesModel struct {
allEntries []worktime.Entry
@@ -33,7 +43,12 @@ type EntriesModel struct {
editMode bool
confirmDelete bool
- input string
+ input string
+ editField entryEditField
+
+ dbDir string
+ statusMessage string
+ statusError bool
}
// NewEntriesModel creates an entry browser model.
@@ -45,6 +60,11 @@ func NewEntriesModel(entries []worktime.Entry) EntriesModel {
return model
}
+// SetPersistence enables edit/delete persistence against dbDir.
+func (m *EntriesModel) SetPersistence(dbDir string) {
+ m.dbDir = strings.TrimSpace(dbDir)
+}
+
// SetSize updates viewport size used for scrolling.
func (m *EntriesModel) SetSize(width, height int) {
m.width = width
@@ -79,7 +99,11 @@ func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
if m.confirmDelete {
switch keyMsg.String() {
case "y":
- m.deleteSelected()
+ if err := m.deleteSelected(); err != nil {
+ m.setStatusError("Delete failed: " + err.Error())
+ } else {
+ m.setStatusInfo("Entry deleted.")
+ }
m.confirmDelete = false
case "n", "esc":
m.confirmDelete = false
@@ -90,7 +114,11 @@ func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
if m.editMode {
switch keyMsg.String() {
case "enter":
- m.saveEdit()
+ if err := m.saveEdit(); err != nil {
+ m.setStatusError("Edit failed: " + err.Error())
+ } else {
+ m.setStatusInfo("Entry updated.")
+ }
m.editMode = false
m.input = ""
case "esc":
@@ -148,7 +176,11 @@ func (m EntriesModel) Update(msg tea.Msg) (EntriesModel, tea.Cmd) {
m.pendingG = false
m.pendingD = false
case "e", "enter":
- m.beginEdit()
+ m.beginEditDescription()
+ m.pendingG = false
+ m.pendingD = false
+ case "v":
+ m.beginEditValue()
m.pendingG = false
m.pendingD = false
case "o":
@@ -231,19 +263,28 @@ func (m EntriesModel) View(styles Styles) string {
return styles.Body.Render(title + "\n\nf " + m.input)
}
if m.editMode {
- return styles.Body.Render(title + "\n\nEdit description: " + m.input)
+ prompt := "Edit description: "
+ if m.editField == entryEditFieldValue {
+ prompt = "Edit value (e.g. 90m, 3600, -600): "
+ }
+ return styles.Body.Render(title + "\n\n" + prompt + m.input)
}
if m.confirmDelete {
return styles.Body.Render(title + "\n\nDelete selected entry? (y/n)")
}
if len(m.visible) == 0 {
- return styles.Body.Render(title + "\n\nNo entries match current search/filter.")
+ body := title + "\n\nNo entries match current search/filter."
+ return styles.Body.Render(body + m.renderStatus(styles))
}
maxRows := m.listRows()
end := minInt(len(m.visible), m.offset+maxRows)
lines := make([]string, 0, end-m.offset)
+ selectedStyle := lipgloss.NewStyle().
+ Background(lipgloss.Color("#28323F")).
+ Bold(true)
+
for idx := m.offset; idx < end; idx++ {
entry := m.visible[idx]
cursor := " "
@@ -255,10 +296,15 @@ func (m EntriesModel) View(styles Styles) string {
category := colorizeCategory(entry.What)
value := formatEntryValue(entry)
line := fmt.Sprintf("%s %s %-7s %-18s %-8s %s", cursor, timestamp, entry.Action, category, value, entry.Descr)
+ if idx == m.cursor {
+ line = selectedStyle.Render(line)
+ }
lines = append(lines, line)
}
- return styles.Body.Render(title + "\n\n" + strings.Join(lines, "\n"))
+ body := title + "\n\n" + strings.Join(lines, "\n")
+ body += "\n\n" + styles.Hint.Render("j/k move, e edit description, v edit value, dd delete")
+ return styles.Body.Render(body + m.renderStatus(styles))
}
func (m *EntriesModel) applyFilters() {
@@ -313,39 +359,79 @@ func (m *EntriesModel) moveCursor(delta int) {
m.ensureCursorVisible()
}
-func (m *EntriesModel) beginEdit() {
+func (m *EntriesModel) beginEditDescription() {
if len(m.visible) == 0 || m.cursor >= len(m.visible) {
return
}
m.editMode = true
+ m.editField = entryEditFieldDescription
m.input = m.visible[m.cursor].Descr
}
-func (m *EntriesModel) saveEdit() {
+func (m *EntriesModel) beginEditValue() {
if len(m.visible) == 0 || m.cursor >= len(m.visible) {
return
}
+ entry := m.visible[m.cursor]
+ if strings.ToLower(strings.TrimSpace(entry.Action)) != "add" {
+ m.setStatusError("Only 'add' entries have an editable value.")
+ return
+ }
+
+ m.editMode = true
+ m.editField = entryEditFieldValue
+ m.input = strconv.FormatInt(entry.Value, 10)
+}
+
+func (m *EntriesModel) saveEdit() error {
+ if len(m.visible) == 0 || m.cursor >= len(m.visible) {
+ return nil
+ }
+
oldEntry := m.visible[m.cursor]
newEntry := oldEntry
- newEntry.Descr = strings.TrimSpace(m.input)
+
+ switch m.editField {
+ case entryEditFieldValue:
+ if strings.ToLower(strings.TrimSpace(newEntry.Action)) != "add" {
+ return errors.New("only 'add' entries have an editable value")
+ }
+ parsedValue, err := duration.Parse(m.input)
+ if err != nil {
+ return err
+ }
+ newEntry.Value = int64(parsedValue / time.Second)
+ default:
+ newEntry.Descr = strings.TrimSpace(m.input)
+ }
+
+ if err := m.persistReplacement(oldEntry, newEntry); err != nil {
+ return err
+ }
m.replaceEntry(oldEntry, newEntry)
+ return nil
}
-func (m *EntriesModel) deleteSelected() {
+func (m *EntriesModel) deleteSelected() error {
if len(m.visible) == 0 || m.cursor >= len(m.visible) {
- return
+ return nil
}
target := m.visible[m.cursor]
idx := findEntryIndex(m.allEntries, target)
if idx < 0 {
- return
+ return errors.New("selected entry not found in current list")
+ }
+
+ if err := m.persistDelete(target); err != nil {
+ return err
}
m.allEntries = append(m.allEntries[:idx], m.allEntries[idx+1:]...)
m.applyFilters()
+ return nil
}
func (m *EntriesModel) insertEntry(above bool) {
@@ -396,6 +482,67 @@ func (m *EntriesModel) replaceEntry(oldEntry, newEntry worktime.Entry) {
}
}
+func (m EntriesModel) persistReplacement(oldEntry, newEntry worktime.Entry) error {
+ if m.dbDir == "" {
+ return nil
+ }
+
+ host := strings.TrimSpace(oldEntry.Source)
+ if host == "" {
+ return errors.New("selected entry has no source host")
+ }
+
+ index, err := findHostEntryIndex(m.dbDir, host, oldEntry)
+ if err != nil {
+ return err
+ }
+
+ newEntry.Source = host
+ _, err = worktime.EditEntry(m.dbDir, host, index, newEntry)
+ return err
+}
+
+func (m EntriesModel) persistDelete(target worktime.Entry) error {
+ if m.dbDir == "" {
+ return nil
+ }
+
+ host := strings.TrimSpace(target.Source)
+ if host == "" {
+ return errors.New("selected entry has no source host")
+ }
+
+ index, err := findHostEntryIndex(m.dbDir, host, target)
+ if err != nil {
+ return err
+ }
+
+ _, err = worktime.DeleteEntry(m.dbDir, host, index)
+ return err
+}
+
+func (m *EntriesModel) setStatusInfo(message string) {
+ m.statusMessage = strings.TrimSpace(message)
+ m.statusError = false
+}
+
+func (m *EntriesModel) setStatusError(message string) {
+ m.statusMessage = strings.TrimSpace(message)
+ m.statusError = true
+}
+
+func (m EntriesModel) renderStatus(styles Styles) string {
+ if strings.TrimSpace(m.statusMessage) == "" {
+ return ""
+ }
+
+ if m.statusError {
+ return "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#FF8B8B")).Render(m.statusMessage)
+ }
+
+ return "\n\n" + styles.Hint.Render(m.statusMessage)
+}
+
func (m *EntriesModel) ensureCursorVisible() {
if len(m.visible) == 0 {
m.cursor = 0
@@ -489,6 +636,22 @@ func findEntryIndex(entries []worktime.Entry, target worktime.Entry) int {
return -1
}
+func findHostEntryIndex(dbDir, host string, target worktime.Entry) (int, error) {
+ db, err := worktime.LoadHost(dbDir, host)
+ if err != nil {
+ return -1, err
+ }
+
+ entries := db.Entries[host]
+ for idx, entry := range entries {
+ if entry == target {
+ return idx, nil
+ }
+ }
+
+ return -1, fmt.Errorf("entry not found in host db %q", host)
+}
+
func insertEntryAt(entries []worktime.Entry, idx int, entry worktime.Entry) []worktime.Entry {
if idx < 0 {
idx = 0
diff --git a/internal/tui/entries_test.go b/internal/tui/entries_test.go
index 09139fc..9377f30 100644
--- a/internal/tui/entries_test.go
+++ b/internal/tui/entries_test.go
@@ -122,6 +122,25 @@ func TestEntriesEditFlow(t *testing.T) {
}
}
+func TestEntriesValueEditFlow(t *testing.T) {
+ model := NewEntriesModel(sampleEntries(3))
+ model.SetSize(120, 12)
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}})
+ if !model.editMode {
+ t.Fatal("editMode = false, want true after v")
+ }
+
+ model.input = "120"
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if model.editMode {
+ t.Fatal("editMode = true, want false after Enter")
+ }
+ if model.visible[0].Value != 120 {
+ t.Fatalf("edited value = %d, want 120", model.visible[0].Value)
+ }
+}
+
func TestEntriesDeleteWithConfirmation(t *testing.T) {
model := NewEntriesModel(sampleEntries(3))
model.SetSize(120, 12)
@@ -168,6 +187,45 @@ func TestEntriesInsertWithOAndShiftO(t *testing.T) {
}
}
+func TestEntriesDeletePersistsToDB(t *testing.T) {
+ dbDir := t.TempDir()
+ host := "host-a"
+
+ db := worktime.Database{
+ Entries: map[string][]worktime.Entry{
+ host: sampleEntries(3),
+ },
+ }
+ if err := worktime.SaveHost(dbDir, host, db); err != nil {
+ t.Fatalf("SaveHost() error = %v", err)
+ }
+
+ entries, err := worktime.LoadAll(dbDir)
+ if err != nil {
+ t.Fatalf("LoadAll() error = %v", err)
+ }
+
+ model := NewEntriesModel(entries)
+ model.SetPersistence(dbDir)
+ model.SetSize(120, 12)
+
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
+ model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
+
+ if len(model.visible) != 2 {
+ t.Fatalf("entries len = %d, want 2 after persisted delete", len(model.visible))
+ }
+
+ reloaded, err := worktime.LoadHost(dbDir, host)
+ if err != nil {
+ t.Fatalf("LoadHost() error = %v", err)
+ }
+ if len(reloaded.Entries[host]) != 2 {
+ t.Fatalf("host entries len = %d, want 2", len(reloaded.Entries[host]))
+ }
+}
+
func sampleEntries(count int) []worktime.Entry {
entries := make([]worktime.Entry, 0, count)
for idx := 0; idx < count; idx++ {
@@ -175,6 +233,8 @@ func sampleEntries(count int) []worktime.Entry {
Action: "add",
What: "work",
Epoch: int64(1000 + idx),
+ Source: "host-a",
+ Human: time.Unix(int64(1000+idx), 0).Format("Mon 02.01.2006 15:04:05"),
Descr: fmt.Sprintf("entry-%d", idx),
Value: int64(time.Hour / time.Second),
})
diff --git a/internal/tui/timer.go b/internal/tui/timer.go
index 4ed3c5f..2c64ef7 100644
--- a/internal/tui/timer.go
+++ b/internal/tui/timer.go
@@ -84,6 +84,12 @@ func NewTimerModel(font string, cfg config.Config) (TimerModel, error) {
return model, nil
}
+// SetSize updates timer viewport dimensions.
+func (m *TimerModel) SetSize(width, height int) {
+ m.width = width
+ m.height = height
+}
+
// Init is called when the model starts.
func (m TimerModel) Init() tea.Cmd {
if m.state.Running {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 51445f2..b345e8b 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -3,6 +3,8 @@ package tui
import (
"strings"
+ "codeberg.org/snonux/timr/internal/config"
+ "codeberg.org/snonux/timr/internal/worktime"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -29,18 +31,59 @@ type Model struct {
pendingZ bool
styles Styles
+
+ entries EntriesModel
+ report ReportModel
+ timer TimerModel
+
+ entriesErr string
+ reportErr string
}
// NewModel creates a new root TUI model.
func NewModel() Model {
- return Model{
+ model, _ := NewModelWithConfig(config.Default())
+ return model
+}
+
+// NewModelWithConfig creates a data-backed root model from config.
+func NewModelWithConfig(cfg config.Config) (Model, error) {
+ model := Model{
activeTab: tabEntries,
styles: DefaultStyles(),
+ entries: NewEntriesModel(nil),
+ report: NewReportModel(nil),
+ }
+
+ entries, err := worktime.LoadAll(cfg.WorktimeDBDir)
+ if err != nil {
+ model.entriesErr = err.Error()
+ } else {
+ model.entries.SetEntries(entries)
+ model.entries.SetPersistence(cfg.WorktimeDBDir)
+ weeks, reportErr := worktime.BuildReport(entries, cfg)
+ if reportErr != nil {
+ model.reportErr = reportErr.Error()
+ } else {
+ model.report.SetWeeks(weeks)
+ }
}
+
+ timerModel, timerErr := NewTimerModel("doom", cfg)
+ if timerErr != nil {
+ model.timer = newFallbackTimerModel("timer init error: " + timerErr.Error())
+ } else {
+ model.timer = timerModel
+ }
+
+ return model, nil
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
+ if m.activeTab == tabTimer && m.timer.state.Running {
+ return timerTick()
+ }
return nil
}
@@ -50,6 +93,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
+ bodyWidth, bodyHeight := m.bodySize()
+ m.entries.SetSize(bodyWidth, bodyHeight)
+ m.report.SetSize(bodyWidth, bodyHeight)
+ m.timer.SetSize(bodyWidth, bodyHeight)
return m, nil
case tea.KeyMsg:
@@ -66,41 +113,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pendingG = false
switch key {
case "t":
- m.nextTab()
- return m, nil
+ return m, m.nextTab()
case "T":
- m.prevTab()
- return m, nil
+ return m, m.prevTab()
}
}
switch key {
case "tab":
- m.nextTab()
+ return m, m.nextTab()
case "1":
- m.activeTab = tabEntries
+ return m, m.switchTab(tabEntries)
case "2":
- m.activeTab = tabReport
+ return m, m.switchTab(tabReport)
case "3":
- m.activeTab = tabTimer
+ return m, m.switchTab(tabTimer)
case "?":
m.showHelp = !m.showHelp
+ return m, nil
case "g":
m.pendingG = true
+ return m, nil
case "Z":
m.pendingZ = true
+ return m, nil
case "q", "ctrl+c":
return m, tea.Quit
}
}
- return m, nil
+ return m.updateActiveTab(msg)
}
// View implements tea.Model.
func (m Model) View() string {
header := m.renderTabs()
- body := m.styles.Body.Render(m.renderBody())
+ body := m.renderBody()
help := m.styles.Hint.Render("Press ? for help")
if m.showHelp {
@@ -116,16 +164,16 @@ func (m Model) View() string {
return rendered
}
-func (m *Model) nextTab() {
- m.activeTab = (m.activeTab + 1) % tabCount
+func (m *Model) nextTab() tea.Cmd {
+ return m.switchTab((m.activeTab + 1) % tabCount)
}
-func (m *Model) prevTab() {
- if m.activeTab == 0 {
- m.activeTab = tabCount - 1
- return
+func (m *Model) prevTab() tea.Cmd {
+ next := m.activeTab - 1
+ if next < 0 {
+ next = tabCount - 1
}
- m.activeTab--
+ return m.switchTab(next)
}
func (m Model) renderTabs() string {
@@ -143,12 +191,79 @@ func (m Model) renderTabs() string {
func (m Model) renderBody() string {
switch m.activeTab {
case tabEntries:
- return "Entries screen scaffold.\nList/search/edit wiring lands in next tasks."
+ if m.entriesErr != "" {
+ return m.styles.Body.Render("Entries\n\nFailed to load entries: " + m.entriesErr)
+ }
+ return m.entries.View(m.styles)
case tabReport:
- return "Report screen scaffold.\nWeekly report table wiring lands in next tasks."
+ if m.entriesErr != "" {
+ return m.styles.Body.Render("Report\n\nUnavailable because entries failed to load: " + m.entriesErr)
+ }
+ if m.reportErr != "" {
+ return m.styles.Body.Render("Report\n\nFailed to build report: " + m.reportErr)
+ }
+ return m.report.View(m.styles)
case tabTimer:
- return "Timer screen scaffold.\nLive timer integration lands in next tasks."
+ return m.timer.View()
default:
return ""
}
}
+
+func newFallbackTimerModel(status string) TimerModel {
+ return TimerModel{
+ helpStyle: lipgloss.NewStyle().Faint(true),
+ timerStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#00BFFF")),
+ statusStyle: lipgloss.NewStyle().Italic(true),
+ font: "doom",
+ workStatus: status,
+ }
+}
+
+func (m *Model) switchTab(next tab) tea.Cmd {
+ m.activeTab = next
+ if m.activeTab == tabTimer && m.timer.state.Running {
+ return timerTick()
+ }
+ return nil
+}
+
+func (m Model) bodySize() (int, int) {
+ width := m.width - 4
+ height := m.height - 6
+
+ if width < 20 {
+ width = m.width
+ }
+ if height < 6 {
+ height = m.height
+ }
+ if width < 1 {
+ width = 1
+ }
+ if height < 1 {
+ height = 1
+ }
+ return width, height
+}
+
+func (m Model) updateActiveTab(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch m.activeTab {
+ case tabEntries:
+ updated, cmd := m.entries.Update(msg)
+ m.entries = updated
+ return m, cmd
+ case tabReport:
+ updated, cmd := m.report.Update(msg)
+ m.report = updated
+ return m, cmd
+ case tabTimer:
+ updatedModel, cmd := m.timer.Update(msg)
+ if updated, ok := updatedModel.(TimerModel); ok {
+ m.timer = updated
+ }
+ return m, cmd
+ default:
+ return m, nil
+ }
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 0b42e1f..2822173 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1,13 +1,17 @@
package tui
import (
+ "path/filepath"
+ "strings"
"testing"
+ "codeberg.org/snonux/timr/internal/config"
+ timrTimer "codeberg.org/snonux/timr/internal/timer"
tea "github.com/charmbracelet/bubbletea"
)
func TestTabNavigation(t *testing.T) {
- model := NewModel()
+ model := newRootModelForTest(t)
modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab})
model = modelAny.(Model)
@@ -31,7 +35,7 @@ func TestTabNavigation(t *testing.T) {
}
func TestHelpToggle(t *testing.T) {
- model := NewModel()
+ model := newRootModelForTest(t)
modelAny, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}})
model = modelAny.(Model)
@@ -47,7 +51,7 @@ func TestHelpToggle(t *testing.T) {
}
func TestQuitKeys(t *testing.T) {
- model := NewModel()
+ model := newRootModelForTest(t)
modelAny, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
model = modelAny.(Model)
@@ -71,9 +75,37 @@ func TestQuitKeys(t *testing.T) {
}
func TestViewContainsTabLabels(t *testing.T) {
- model := NewModel()
+ model := newRootModelForTest(t)
view := model.View()
if view == "" {
t.Fatal("View() returned empty output")
}
}
+
+func TestEntriesTabUsesEntriesModelView(t *testing.T) {
+ model := newRootModelForTest(t)
+ view := model.renderBody()
+ if strings.Contains(view, "scaffold") {
+ t.Fatalf("renderBody() should not return scaffold text: %q", view)
+ }
+}
+
+func newRootModelForTest(t *testing.T) Model {
+ t.Helper()
+
+ tempDir := t.TempDir()
+ timrTimer.SetStateFilePathOverride(filepath.Join(tempDir, ".timr_state"))
+ t.Cleanup(func() {
+ timrTimer.SetStateFilePathOverride("")
+ })
+
+ cfg := config.Default()
+ cfg.WorktimeDBDir = tempDir
+ cfg.Hostname = "host-a"
+
+ model, err := NewModelWithConfig(cfg)
+ if err != nil {
+ t.Fatalf("NewModelWithConfig() error = %v", err)
+ }
+ return model
+}
diff --git a/internal/worktime/db.go b/internal/worktime/db.go
index ad87c4d..e6b06f4 100644
--- a/internal/worktime/db.go
+++ b/internal/worktime/db.go
@@ -4,9 +4,11 @@ import (
"encoding/json"
"errors"
"fmt"
+ "math"
"os"
"path/filepath"
"sort"
+ "strconv"
"strings"
)
@@ -23,6 +25,51 @@ type Entry struct {
Descr string `json:"descr,omitempty"`
}
+// UnmarshalJSON supports legacy value encodings where "value" can be int or float.
+func (e *Entry) UnmarshalJSON(data []byte) error {
+ type entryAlias Entry
+ aux := struct {
+ entryAlias
+ Value json.RawMessage `json:"value"`
+ }{}
+
+ if err := json.Unmarshal(data, &aux); err != nil {
+ return err
+ }
+
+ *e = Entry(aux.entryAlias)
+ e.Value = 0
+
+ raw := strings.TrimSpace(string(aux.Value))
+ if raw == "" || raw == "null" {
+ return nil
+ }
+
+ var intValue int64
+ if err := json.Unmarshal(aux.Value, &intValue); err == nil {
+ e.Value = intValue
+ return nil
+ }
+
+ var floatValue float64
+ if err := json.Unmarshal(aux.Value, &floatValue); err == nil {
+ e.Value = int64(math.Round(floatValue))
+ return nil
+ }
+
+ var stringValue string
+ if err := json.Unmarshal(aux.Value, &stringValue); err == nil {
+ parsed, parseErr := strconv.ParseFloat(strings.TrimSpace(stringValue), 64)
+ if parseErr != nil {
+ return fmt.Errorf("parse string value %q: %w", stringValue, parseErr)
+ }
+ e.Value = int64(math.Round(parsed))
+ return nil
+ }
+
+ return fmt.Errorf("unsupported value encoding %s", raw)
+}
+
// Database is the on-disk JSON structure used by worktime.
type Database struct {
Entries map[string][]Entry `json:"entries"`
@@ -45,7 +92,12 @@ func LoadAll(dbDir string) ([]Entry, error) {
if err != nil {
return nil, err
}
- for _, hostEntries := range db.Entries {
+ for host, hostEntries := range db.Entries {
+ for idx := range hostEntries {
+ if strings.TrimSpace(hostEntries[idx].Source) == "" {
+ hostEntries[idx].Source = host
+ }
+ }
entries = append(entries, hostEntries...)
}
}
@@ -76,6 +128,11 @@ func LoadHost(dbDir, hostname string) (Database, error) {
if _, ok := db.Entries[host]; !ok {
db.Entries[host] = []Entry{}
}
+ for idx := range db.Entries[host] {
+ if strings.TrimSpace(db.Entries[host][idx].Source) == "" {
+ db.Entries[host][idx].Source = host
+ }
+ }
sortEntries(db.Entries[host])
return db, nil
}
diff --git a/internal/worktime/db_test.go b/internal/worktime/db_test.go
index d011a9e..f599fa1 100644
--- a/internal/worktime/db_test.go
+++ b/internal/worktime/db_test.go
@@ -129,6 +129,32 @@ func TestLoadAllMergesAndSortsEntries(t *testing.T) {
}
}
+func TestLoadAllBackfillsMissingSourceFromHost(t *testing.T) {
+ dbDir := t.TempDir()
+ dbFile := filepath.Join(dbDir, "db.host-a.json")
+ content := `{
+ "entries": {
+ "host-a": [
+ {"action":"login","what":"work","epoch":10,"human":"h1"}
+ ]
+ }
+}`
+ if err := os.WriteFile(dbFile, []byte(content), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ entries, err := LoadAll(dbDir)
+ if err != nil {
+ t.Fatalf("LoadAll() error = %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("entries len = %d, want 1", len(entries))
+ }
+ if entries[0].Source != "host-a" {
+ t.Fatalf("entries[0].Source = %q, want host-a", entries[0].Source)
+ }
+}
+
func TestLoadAllOnMissingDirectoryReturnsEmptySlice(t *testing.T) {
dbDir := filepath.Join(t.TempDir(), "does-not-exist")
@@ -159,6 +185,32 @@ func TestLoadHostInvalidJSON(t *testing.T) {
}
}
+func TestLoadAllAcceptsFloatValueEncoding(t *testing.T) {
+ dbDir := t.TempDir()
+ dbFile := filepath.Join(dbDir, "db.host-a.json")
+ content := `{
+ "entries": {
+ "host-a": [
+ {"action":"add","what":"work","epoch":10,"source":"host-a","human":"h1","value":31680.000000000004}
+ ]
+ }
+}`
+ if err := os.WriteFile(dbFile, []byte(content), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ entries, err := LoadAll(dbDir)
+ if err != nil {
+ t.Fatalf("LoadAll() error = %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("entries len = %d, want 1", len(entries))
+ }
+ if entries[0].Value != 31680 {
+ t.Fatalf("entries[0].Value = %d, want 31680", entries[0].Value)
+ }
+}
+
func TestLoadAllInvalidJSON(t *testing.T) {
dbDir := t.TempDir()
badFile := filepath.Join(dbDir, "db.host-a.json")