From 79b0821837a93c30071e0a71e12310f81241ae1d Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 27 Oct 2026 22:59:40 +0100 Subject: [PATCH] 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 --- internal/server/time_handlers.go | 21 +++- internal/server/time_handlers_test.go | 130 ++++++++++++++++++++++ internal/ui/app.go | 64 +++++++---- internal/ui/helpbar.go | 3 +- internal/ui/layout.go | 4 +- internal/ui/views/timeedit.go | 154 ++++++++++++++++++++++++++ internal/ui/views/timeedit_test.go | 34 ++++++ internal/ui/views/timeentries.go | 2 +- web/src/App.svelte | 103 +++++++++++++++-- web/src/lib/api.js | 4 + 10 files changed, 479 insertions(+), 40 deletions(-) create mode 100644 internal/server/time_handlers_test.go create mode 100644 internal/ui/views/timeedit.go create mode 100644 internal/ui/views/timeedit_test.go diff --git a/internal/server/time_handlers.go b/internal/server/time_handlers.go index de3d79c..8c18a32 100644 --- a/internal/server/time_handlers.go +++ b/internal/server/time_handlers.go @@ -63,8 +63,10 @@ func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) { return } var body struct { - Duration int64 `json:"durationNs"` - Note string `json:"note"` + Start *time.Time `json:"start"` + End *time.Time `json:"end"` + Duration int64 `json:"durationNs"` + Note string `json:"note"` } if err := readJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") @@ -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 { diff --git a/internal/server/time_handlers_test.go b/internal/server/time_handlers_test.go new file mode 100644 index 0000000..4dbe85d --- /dev/null +++ b/internal/server/time_handlers_test.go @@ -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()) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 1de2921..b2ed851 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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, diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index d060827..f515c02 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -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"}, } diff --git a/internal/ui/layout.go b/internal/ui/layout.go index c61d5ff..2c073e2 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -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) diff --git a/internal/ui/views/timeedit.go b/internal/ui/views/timeedit.go new file mode 100644 index 0000000..adfa3b2 --- /dev/null +++ b/internal/ui/views/timeedit.go @@ -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() +} diff --git a/internal/ui/views/timeedit_test.go b/internal/ui/views/timeedit_test.go new file mode 100644 index 0000000..7c3f099 --- /dev/null +++ b/internal/ui/views/timeedit_test.go @@ -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") + } +} diff --git a/internal/ui/views/timeentries.go b/internal/ui/views/timeentries.go index d5d3a90..e5ceff8 100644 --- a/internal/ui/views/timeentries.go +++ b/internal/ui/views/timeentries.go @@ -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 diff --git a/web/src/App.svelte b/web/src/App.svelte index 2c709c5..06cc6d2 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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 @@ - + {#each ticketTime as e} - - - - - + {#if editingTimeId === e.id} + + + + {:else} + + + + + + + {/if} {:else} - + {/each}
WhenDurationSource
WhenDurationSource
{e.start ? new Date(e.start).toLocaleString() : '—'}{formatDuration(e.duration)}{e.source}
+
+ + + + +
+
{e.start ? new Date(e.start).toLocaleString() : '—'}{formatDuration(e.duration)}{e.source} + + +
No time yet.
No time yet.
diff --git a/web/src/lib/api.js b/web/src/lib/api.js index d252402..a2e4fec 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -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 }) }),