From 5cbfc58aa38f91f9b2038f7e7383865cb52a3d34 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 1 Feb 2026 21:46:11 +0100 Subject: [PATCH] Add time editing --- pages/timeEditor.go | 168 ++++++++++++++++++++++++++++++++++++++++++ pages/timePage.go | 24 ++++++ timewarrior/models.go | 2 + 3 files changed, 194 insertions(+) create mode 100644 pages/timeEditor.go diff --git a/pages/timeEditor.go b/pages/timeEditor.go new file mode 100644 index 0000000..3837dd6 --- /dev/null +++ b/pages/timeEditor.go @@ -0,0 +1,168 @@ +package pages + +import ( + "log/slog" + "strings" + "tasksquire/common" + "tasksquire/timewarrior" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type TimeEditorPage struct { + common *common.Common + interval *timewarrior.Interval + form *huh.Form + + startStr string + endStr string + tagsStr string +} + +func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { + p := &TimeEditorPage{ + common: com, + interval: interval, + startStr: interval.Start, + endStr: interval.End, + tagsStr: formatTags(interval.Tags), + } + + p.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Start"). + Value(&p.startStr). + Validate(func(s string) error { + return timewarrior.ValidateDate(s) + }), + huh.NewInput(). + Title("End"). + Value(&p.endStr). + Validate(func(s string) error { + if s == "" { + return nil // End can be empty (active) + } + return timewarrior.ValidateDate(s) + }), + huh.NewInput(). + Title("Tags"). + Value(&p.tagsStr). + Description("Space separated, use \"\" for tags with spaces"), + ), + ).WithTheme(com.Styles.Form) + + return p +} + +func (p *TimeEditorPage) Init() tea.Cmd { + return p.form.Init() +} + +func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if key.Matches(msg, p.common.Keymap.Back) { + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, BackCmd + } + case tea.WindowSizeMsg: + p.SetSize(msg.Width, msg.Height) + } + + form, cmd := p.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + p.form = f + } + cmds = append(cmds, cmd) + + if p.form.State == huh.StateCompleted { + p.saveInterval() + + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + // Return with a command to refresh the intervals + return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) + } + + return p, tea.Batch(cmds...) +} + +func (p *TimeEditorPage) View() string { + return p.form.View() +} + +func (p *TimeEditorPage) SetSize(width, height int) { + p.common.SetSize(width, height) +} + +func (p *TimeEditorPage) saveInterval() { + // If it's an existing interval (has ID), delete it first so we can replace it with the modified version + if p.interval.ID != 0 { + err := p.common.TimeW.DeleteInterval(p.interval.ID) + if err != nil { + slog.Error("Failed to delete old interval during edit", "err", err) + // Proceeding to import anyway, attempting to save user data + } + } + + p.interval.Start = p.startStr + p.interval.End = p.endStr + + // Parse tags + p.interval.Tags = parseTags(p.tagsStr) + + err := p.common.TimeW.ModifyInterval(p.interval) + if err != nil { + slog.Error("Failed to modify interval", "err", err) + } +} + +func parseTags(tagsStr string) []string { + var tags []string + var current strings.Builder + inQuotes := false + + for _, r := range tagsStr { + switch { + case r == '"': + inQuotes = !inQuotes + case r == ' ' && !inQuotes: + if current.Len() > 0 { + tags = append(tags, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 { + tags = append(tags, current.String()) + } + return tags +} + +func formatTags(tags []string) string { + var formatted []string + for _, t := range tags { + if strings.Contains(t, " ") { + formatted = append(formatted, "\""+t+"\"") + } else { + formatted = append(formatted, t) + } + } + return strings.Join(formatted, " ") +} + + \ No newline at end of file diff --git a/pages/timePage.go b/pages/timePage.go index 599e55d..83a54c4 100644 --- a/pages/timePage.go +++ b/pages/timePage.go @@ -1,6 +1,8 @@ package pages import ( + "time" + "tasksquire/common" "tasksquire/components/timetable" "tasksquire/timewarrior" @@ -39,6 +41,8 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case intervalsMsg: p.data = timewarrior.Intervals(msg) p.populateTable(p.data) + case RefreshIntervalsMsg: + cmds = append(cmds, p.getIntervals()) case tickMsg: cmds = append(cmds, p.getIntervals()) cmds = append(cmds, doTick()) @@ -65,6 +69,20 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.common.TimeW.DeleteInterval(interval.ID) return p, tea.Batch(p.getIntervals(), doTick()) } + case key.Matches(msg, p.common.Keymap.Edit): + row := p.intervals.SelectedRow() + if row != nil { + interval := (*timewarrior.Interval)(row) + editor := NewTimeEditorPage(p.common, interval) + p.common.PushPage(p) + return editor, editor.Init() + } + case key.Matches(msg, p.common.Keymap.Add): + interval := timewarrior.NewInterval() + interval.Start = time.Now().UTC().Format("20060102T150405Z") + editor := NewTimeEditorPage(p.common, interval) + p.common.PushPage(p) + return editor, editor.Init() } } @@ -75,6 +93,12 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Batch(cmds...) } +type RefreshIntervalsMsg struct{} + +func refreshIntervals() tea.Msg { + return RefreshIntervalsMsg{} +} + func (p *TimePage) View() string { if len(p.data) == 0 { return p.common.Styles.Base.Render("No intervals found for today") diff --git a/timewarrior/models.go b/timewarrior/models.go index 8816677..fb60db7 100644 --- a/timewarrior/models.go +++ b/timewarrior/models.go @@ -144,6 +144,8 @@ func formatDate(date string, format string) string { slog.Error("Failed to parse time:", err) return "" } + + dt = dt.Local() switch format { case "formatted", "":