Allow editing time entry start and end in TUI and web.

Duration is derived from the range so timer sessions can be corrected after the fact.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 22:59:40 +01:00
co-authored by Cursor
parent afbf86dcf2
commit 79b0821837
10 changed files with 479 additions and 40 deletions
+154
View File
@@ -0,0 +1,154 @@
package views
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"task/internal/ticket"
)
// Time edit field indices.
const (
FieldTimeStart = iota
FieldTimeEnd
TimeEditFieldCount
)
const timeEditLayout = "2006-01-02 15:04"
// TimeEditStyles styles the time-entry edit form.
type TimeEditStyles struct {
Label lipgloss.Style
Focused lipgloss.Style
Blurred lipgloss.Style
Hint lipgloss.Style
Title lipgloss.Style
}
// DefaultTimeEditStyles returns sensible defaults.
func DefaultTimeEditStyles() TimeEditStyles {
return TimeEditStyles{
Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")),
Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")),
Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
Title: lipgloss.NewStyle().Bold(true),
}
}
// TimeEditForm edits start and end of a time entry.
type TimeEditForm struct {
Inputs []textinput.Model
Focus int
Styles TimeEditStyles
Width int
}
// NewTimeEditForm builds a form prefilled from an entry.
func NewTimeEditForm(e ticket.TimeEntry, width int) TimeEditForm {
styles := DefaultTimeEditStyles()
inputs := make([]textinput.Model, TimeEditFieldCount)
mk := func(placeholder string) textinput.Model {
ti := textinput.New()
ti.Placeholder = placeholder
ti.CharLimit = 16
ti.Width = max(20, width-10)
return ti
}
inputs[FieldTimeStart] = mk(timeEditLayout)
inputs[FieldTimeEnd] = mk(timeEditLayout)
if !e.Start.IsZero() {
inputs[FieldTimeStart].SetValue(e.Start.Local().Format(timeEditLayout))
}
if !e.End.IsZero() {
inputs[FieldTimeEnd].SetValue(e.End.Local().Format(timeEditLayout))
} else if !e.Start.IsZero() && e.Duration > 0 {
inputs[FieldTimeEnd].SetValue(e.Start.Add(e.Duration).Local().Format(timeEditLayout))
}
f := TimeEditForm{
Inputs: inputs,
Focus: FieldTimeStart,
Styles: styles,
Width: width,
}
f.focusIndex(FieldTimeStart)
return f
}
func (f *TimeEditForm) focusIndex(i int) {
f.Focus = i
for idx := range f.Inputs {
if idx == i {
f.Inputs[idx].Focus()
f.Inputs[idx].PromptStyle = f.Styles.Focused
f.Inputs[idx].TextStyle = f.Styles.Focused
} else {
f.Inputs[idx].Blur()
f.Inputs[idx].PromptStyle = f.Styles.Blurred
f.Inputs[idx].TextStyle = f.Styles.Blurred
}
}
}
// NextField moves focus to the next field.
func (f *TimeEditForm) NextField() {
f.focusIndex((f.Focus + 1) % TimeEditFieldCount)
}
// PrevField moves focus to the previous field.
func (f *TimeEditForm) PrevField() {
f.focusIndex((f.Focus - 1 + TimeEditFieldCount) % TimeEditFieldCount)
}
// Update forwards a message to the focused text input.
func (f *TimeEditForm) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg)
return cmd
}
// Parse returns start and end in UTC, or an error.
func (f TimeEditForm) Parse() (start, end time.Time, err error) {
startRaw := strings.TrimSpace(f.Inputs[FieldTimeStart].Value())
endRaw := strings.TrimSpace(f.Inputs[FieldTimeEnd].Value())
if startRaw == "" || endRaw == "" {
return time.Time{}, time.Time{}, fmt.Errorf("start and end are required")
}
start, err = time.ParseInLocation(timeEditLayout, startRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("start: use %s", timeEditLayout)
}
end, err = time.ParseInLocation(timeEditLayout, endRaw, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("end: use %s", timeEditLayout)
}
if !end.After(start) {
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
}
return start.UTC(), end.UTC(), nil
}
// View renders the form.
func (f TimeEditForm) View() string {
var b strings.Builder
b.WriteString(f.Styles.Title.Render("Edit time entry"))
b.WriteString("\n\n")
labels := []string{"Start", "End"}
for i, label := range labels {
b.WriteString(f.Styles.Label.Render(label+":"))
b.WriteString(" ")
b.WriteString(f.Inputs[i].View())
b.WriteString("\n")
}
b.WriteString("\n")
b.WriteString(f.Styles.Hint.Render("format " + timeEditLayout + " · tab fields · enter save · esc cancel"))
return b.String()
}
+34
View File
@@ -0,0 +1,34 @@
package views
import (
"testing"
"time"
"task/internal/ticket"
)
func TestTimeEditFormParse(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
end := start.Add(90 * time.Minute)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: end, Duration: 90 * time.Minute}, 80)
gotStart, gotEnd, err := f.Parse()
if err != nil {
t.Fatal(err)
}
if !gotStart.Equal(start.UTC()) {
t.Fatalf("start: got %v want %v", gotStart, start.UTC())
}
if !gotEnd.Equal(end.UTC()) {
t.Fatalf("end: got %v want %v", gotEnd, end.UTC())
}
}
func TestTimeEditFormRejectsBadRange(t *testing.T) {
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.Local)
f := NewTimeEditForm(ticket.TimeEntry{Start: start, End: start, Duration: time.Minute}, 80)
f.Inputs[FieldTimeEnd].SetValue(start.Format(timeEditLayout))
if _, _, err := f.Parse(); err == nil {
t.Fatal("expected error for end == start")
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func (v TimeEntriesView) View() string {
title = "ticket"
}
head := v.Styles.Title.Render(fmt.Sprintf("Time entries · %s", truncateOneLine(title, max(8, v.Width-20))))
hint := v.Styles.Hint.Render("j/k navigate · e edit · x delete · esc back")
hint := v.Styles.Hint.Render("j/k navigate · e edit start/end · x delete · esc back")
innerW := max(20, v.Width-2)
var lines []string