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:
@@ -63,6 +63,8 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Start *time.Time `json:"start"`
|
||||
End *time.Time `json:"end"`
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
@@ -86,8 +88,21 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if body.Duration > 0 {
|
||||
if body.Start != nil || body.End != nil {
|
||||
if body.Start != nil {
|
||||
found.Start = body.Start.UTC()
|
||||
}
|
||||
if body.End != nil {
|
||||
found.End = body.End.UTC()
|
||||
}
|
||||
if !found.End.After(found.Start) {
|
||||
writeError(w, http.StatusBadRequest, "end must be after start")
|
||||
return
|
||||
}
|
||||
found.Duration = found.End.Sub(found.Start)
|
||||
} else if body.Duration > 0 {
|
||||
found.Duration = time.Duration(body.Duration)
|
||||
found.End = found.Start.Add(found.Duration)
|
||||
}
|
||||
found.Note = body.Note
|
||||
if err := s.Times.Update(*found); err != nil {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestUpdateTimeStartEnd(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Work"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ticket map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||
tid, _ := ticket["id"].(string)
|
||||
|
||||
timeBody, _ := json.Marshal(map[string]any{
|
||||
"ticketId": tid,
|
||||
"durationNs": int64(30 * time.Minute),
|
||||
"source": "manual",
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/time", bytes.NewReader(timeBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create time %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var entry map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||
eid, _ := entry["id"].(string)
|
||||
if eid == "" {
|
||||
t.Fatal("no entry id")
|
||||
}
|
||||
|
||||
start := time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
end := start.Add(90 * time.Minute)
|
||||
patchBody, _ := json.Marshal(map[string]any{
|
||||
"start": start,
|
||||
"end": end,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch start/end %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||
dur, _ := entry["duration"].(float64)
|
||||
if time.Duration(dur) != 90*time.Minute {
|
||||
t.Fatalf("duration want 90m got %v", time.Duration(dur))
|
||||
}
|
||||
|
||||
// duration-only still works
|
||||
patchBody, _ = json.Marshal(map[string]any{"durationNs": int64(45 * time.Minute)})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch duration %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &entry)
|
||||
dur, _ = entry["duration"].(float64)
|
||||
if time.Duration(dur) != 45*time.Minute {
|
||||
t.Fatalf("duration want 45m got %v", time.Duration(dur))
|
||||
}
|
||||
|
||||
// invalid range
|
||||
badEnd := start.Add(-time.Minute)
|
||||
patchBody, _ = json.Marshal(map[string]any{"start": start, "end": badEnd})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+pid+"/time/"+eid, bytes.NewReader(patchBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid range want 400 got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
+43
-21
@@ -88,6 +88,7 @@ type Model struct {
|
||||
timeEntries views.TimeEntriesView
|
||||
timeEntriesTicket string // ticket ID for ModeTimeEntries
|
||||
editingTimeEntry ticket.TimeEntry
|
||||
timeEdit views.TimeEditForm
|
||||
activeTimer *ticket.ActiveTimer
|
||||
spentByID map[string]time.Duration
|
||||
width int
|
||||
@@ -207,8 +208,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||
return m.updateInput(msg)
|
||||
case ModeTimeEdit:
|
||||
return m.updateTimeEdit(msg)
|
||||
case ModeEditProject:
|
||||
return m.updateEditProject(msg)
|
||||
case ModeProjects:
|
||||
@@ -238,8 +241,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
case ModeProjects, ModeMoveTicket:
|
||||
m.projectList, cmd = m.projectList.Update(msg)
|
||||
case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit:
|
||||
case ModeCreate, ModeSearch, ModeTimeManual:
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
case ModeTimeEdit:
|
||||
cmd = m.timeEdit.Update(msg)
|
||||
case ModeEditProject:
|
||||
cmd = m.projectEdit.Update(msg)
|
||||
}
|
||||
@@ -585,8 +590,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case wasProject:
|
||||
m.mode = ModeProjects
|
||||
case m.mode == ModeTimeEdit:
|
||||
m.mode = ModeTimeEntries
|
||||
default:
|
||||
m.mode = ModeList
|
||||
}
|
||||
@@ -608,16 +611,6 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if m.mode == ModeTimeEdit {
|
||||
if err := m.saveEditedTime(value); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.input.Blur()
|
||||
m.mode = ModeTimeEntries
|
||||
m.setStatus("time updated")
|
||||
return m, nil
|
||||
}
|
||||
if m.mode == ModeTimeManual {
|
||||
if err := m.addManualTime(value); err != nil {
|
||||
m.setError(err)
|
||||
@@ -708,10 +701,8 @@ func (m Model) updateTimeEntries(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.editingTimeEntry = e
|
||||
m.timeEdit = views.NewTimeEditForm(e, m.width)
|
||||
m.mode = ModeTimeEdit
|
||||
m.input = views.NewTimeManualInput()
|
||||
m.input.SetValue(ticket.FormatDurationHuman(e.Duration))
|
||||
m.input.Focus()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Undo):
|
||||
@@ -1717,18 +1708,49 @@ func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) saveEditedTime(raw string) error {
|
||||
func (m Model) updateTimeEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Escape):
|
||||
m.mode = ModeTimeEntries
|
||||
m.clearStatus()
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, m.keys.Tab), msg.Type == tea.KeyDown:
|
||||
m.timeEdit.NextField()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.ShiftTab), msg.Type == tea.KeyUp:
|
||||
m.timeEdit.PrevField()
|
||||
return m, textinput.Blink
|
||||
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
m.clearStatus()
|
||||
if err := m.saveEditedTime(); err != nil {
|
||||
m.setError(err)
|
||||
return m, nil
|
||||
}
|
||||
m.mode = ModeTimeEntries
|
||||
m.setStatus("time updated")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
cmd := m.timeEdit.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) saveEditedTime() error {
|
||||
if m.timeStore == nil {
|
||||
return fmt.Errorf("time store not configured")
|
||||
}
|
||||
dur, err := ticket.ParseDurationFlexible(raw)
|
||||
start, end, err := m.timeEdit.Parse()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := m.editingTimeEntry
|
||||
after := before
|
||||
after.Duration = dur
|
||||
after.End = after.Start.Add(dur)
|
||||
after.Start = start
|
||||
after.End = end
|
||||
after.Duration = end.Sub(start)
|
||||
if err := m.exec.Do(&command.UpdateTimeEntry{
|
||||
Store: m.timeStore,
|
||||
Before: before,
|
||||
|
||||
@@ -87,13 +87,14 @@ func (m Model) helpEntries() []helpEntry {
|
||||
case ModeTimeEntries:
|
||||
return []helpEntry{
|
||||
{"j/k", "navigate"},
|
||||
{"e", "edit"},
|
||||
{"e", "edit times"},
|
||||
{"x", "delete"},
|
||||
{"u/U", "undo/redo"},
|
||||
{"esc", "back"},
|
||||
}
|
||||
case ModeTimeEdit:
|
||||
return []helpEntry{
|
||||
{"tab/↑↓", "fields"},
|
||||
{"enter", "save"},
|
||||
{"esc", "cancel"},
|
||||
}
|
||||
|
||||
@@ -60,9 +60,7 @@ func (m Model) renderBody(height int) string {
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
|
||||
case ModeTimeEdit:
|
||||
prompt := m.styles.InputPrompt.Render("Edit duration:")
|
||||
content := prompt + "\n" + m.input.View()
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(content)
|
||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.timeEdit.View())
|
||||
|
||||
case ModeTimeEntries:
|
||||
m.timeEntries.SetSize(m.width, height)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+87
-6
@@ -43,6 +43,9 @@
|
||||
let memberUserId = $state('')
|
||||
let memberRole = $state('member')
|
||||
let timeMinutes = $state(30)
|
||||
let editingTimeId = $state('')
|
||||
let editTimeStart = $state('')
|
||||
let editTimeEnd = $state('')
|
||||
let tokenName = $state('tui')
|
||||
let tokenScope = $state('write')
|
||||
|
||||
@@ -67,6 +70,64 @@
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
function toLocalInput(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function refreshTicketTime() {
|
||||
if (!selected) return
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
}
|
||||
|
||||
function beginEditTime(e) {
|
||||
editingTimeId = e.id
|
||||
editTimeStart = toLocalInput(e.start)
|
||||
editTimeEnd = toLocalInput(e.end || (e.start && e.duration
|
||||
? new Date(new Date(e.start).getTime() + Number(e.duration)).toISOString()
|
||||
: ''))
|
||||
}
|
||||
|
||||
function cancelEditTime() {
|
||||
editingTimeId = ''
|
||||
editTimeStart = ''
|
||||
editTimeEnd = ''
|
||||
}
|
||||
|
||||
async function saveTimeEntry() {
|
||||
if (!selected || !editingTimeId) return
|
||||
if (!editTimeStart || !editTimeEnd) {
|
||||
error = 'Start and end are required'
|
||||
return
|
||||
}
|
||||
const start = new Date(editTimeStart)
|
||||
const end = new Date(editTimeEnd)
|
||||
if (!(end > start)) {
|
||||
error = 'End must be after start'
|
||||
return
|
||||
}
|
||||
error = ''
|
||||
await api.patchTime(projectId, editingTimeId, {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
})
|
||||
cancelEditTime()
|
||||
await refreshTicketTime()
|
||||
msg = 'Time updated'
|
||||
}
|
||||
|
||||
async function removeTimeEntry(eid) {
|
||||
if (!confirm('Delete time entry?')) return
|
||||
await api.deleteTime(projectId, eid)
|
||||
if (editingTimeId === eid) cancelEditTime()
|
||||
await refreshTicketTime()
|
||||
msg = 'Time deleted'
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
error = ''
|
||||
try {
|
||||
@@ -182,6 +243,7 @@
|
||||
editTitle = t.title
|
||||
editDesc = t.description || ''
|
||||
editStatus = t.status
|
||||
cancelEditTime()
|
||||
revisions = await api.listRevisions(projectId, t.id)
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === t.id)
|
||||
@@ -226,8 +288,7 @@
|
||||
durationNs: timeMinutes * 60 * 1e9,
|
||||
source: 'manual',
|
||||
})
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
await refreshTicketTime()
|
||||
msg = `Booked ${timeMinutes}m`
|
||||
}
|
||||
|
||||
@@ -239,8 +300,7 @@
|
||||
await api.startTimer(selected.id, projectId)
|
||||
}
|
||||
await refreshTimer()
|
||||
const all = await api.listTime(projectId)
|
||||
ticketTime = all.filter((e) => e.ticketId === selected.id)
|
||||
await refreshTicketTime()
|
||||
}
|
||||
|
||||
async function openReport() {
|
||||
@@ -570,16 +630,37 @@
|
||||
<button onclick={bookTime}>Book</button>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th></tr></thead>
|
||||
<thead><tr><th>When</th><th>Duration</th><th>Source</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{#each ticketTime as e}
|
||||
{#if editingTimeId === e.id}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="row" style="flex-wrap: wrap; gap: 0.5rem;">
|
||||
<label class="row" style="width: auto;">Start
|
||||
<input type="datetime-local" bind:value={editTimeStart} style="width: auto;" />
|
||||
</label>
|
||||
<label class="row" style="width: auto;">End
|
||||
<input type="datetime-local" bind:value={editTimeEnd} style="width: auto;" />
|
||||
</label>
|
||||
<button class="primary" onclick={saveTimeEntry}>Save</button>
|
||||
<button onclick={cancelEditTime}>Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td class="muted">{e.start ? new Date(e.start).toLocaleString() : '—'}</td>
|
||||
<td>{formatDuration(e.duration)}</td>
|
||||
<td>{e.source}</td>
|
||||
<td class="row" style="width: auto; justify-content: flex-end;">
|
||||
<button onclick={() => beginEditTime(e)}>Edit</button>
|
||||
<button class="danger" onclick={() => removeTimeEntry(e.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{:else}
|
||||
<tr><td colspan="3" class="muted">No time yet.</td></tr>
|
||||
<tr><td colspan="4" class="muted">No time yet.</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -76,6 +76,10 @@ export const api = {
|
||||
listTime: (projectId) => request(`/projects/${projectId}/time`),
|
||||
createTime: (projectId, body) =>
|
||||
request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patchTime: (projectId, eid, body) =>
|
||||
request(`/projects/${projectId}/time/${eid}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
deleteTime: (projectId, eid) =>
|
||||
request(`/projects/${projectId}/time/${eid}`, { method: 'DELETE' }),
|
||||
getTimer: () => request('/timer'),
|
||||
startTimer: (ticketId, projectId) =>
|
||||
request('/timer/start', { method: 'POST', body: JSON.stringify({ ticketId, projectId }) }),
|
||||
|
||||
Reference in New Issue
Block a user