diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 23:42:04 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 23:42:04 +0200 |
| commit | 293406c3bd2acc85490da11afabaf6733babd5e4 (patch) | |
| tree | 1d3df45b9fb4511c37feaeacc885e1913e7f8ed6 | |
| parent | 2c414a53dc95c75584398246beb34f7ddf181448 (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.go | 16 | ||||
| -rw-r--r-- | internal/cli/tui.go | 5 | ||||
| -rw-r--r-- | internal/tui/entries.go | 189 | ||||
| -rw-r--r-- | internal/tui/entries_test.go | 60 | ||||
| -rw-r--r-- | internal/tui/timer.go | 6 | ||||
| -rw-r--r-- | internal/tui/tui.go | 157 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 40 | ||||
| -rw-r--r-- | internal/worktime/db.go | 59 | ||||
| -rw-r--r-- | internal/worktime/db_test.go | 52 |
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") |
