Files
task-master/internal/server/time_handlers.go
T
skrueckenandCursor 79b0821837 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>
2026-10-27 22:59:40 +01:00

213 lines
5.5 KiB
Go

package server
import (
"net/http"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
writeError(w, code, "forbidden")
return
}
list, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, list)
}
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
TicketID string `json:"ticketId"`
Duration int64 `json:"durationNs"`
Note string `json:"note"`
Source string `json:"source"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: body.TicketID,
ProjectID: id,
Duration: time.Duration(body.Duration),
Note: body.Note,
Source: body.Source,
})
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
writeJSON(w, http.StatusCreated, e)
}
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
var body struct {
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")
return
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var found *ticket.TimeEntry
for i := range entries {
if entries[i].ID == eid {
found = &entries[i]
break
}
}
if found == nil {
writeError(w, http.StatusNotFound, "not found")
return
}
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 {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
writeJSON(w, http.StatusOK, found)
}
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
eid := r.PathValue("eid")
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
if !ok {
writeError(w, code, "forbidden")
return
}
entries, _ := s.Times.ListByProject(id)
for _, e := range entries {
if e.ID == eid {
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
break
}
}
if err := s.Times.Delete(eid); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
t, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if t == nil {
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
}
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
var body struct {
TicketID string `json:"ticketId"`
ProjectID string `json:"projectId"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
t := &ticket.ActiveTimer{
TicketID: body.TicketID,
ProjectID: body.ProjectID,
StartedAt: time.Now().UTC(),
}
if err := s.Times.SaveActiveTimer(t); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = u
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r)
active, err := s.Times.LoadActiveTimer()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if active == nil {
writeError(w, http.StatusBadRequest, "no active timer")
return
}
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
writeError(w, code, "forbidden")
return
}
dur := time.Since(active.StartedAt)
if dur < time.Second {
dur = time.Second
}
e, err := s.Times.Create(ticket.TimeEntry{
TicketID: active.TicketID,
ProjectID: active.ProjectID,
Start: active.StartedAt,
End: time.Now().UTC(),
Duration: dur,
Source: ticket.SourceTimer,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
_ = s.Times.SaveActiveTimer(nil)
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
writeJSON(w, http.StatusOK, e)
}