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
+18 -3
View File
@@ -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 {
+130
View File
@@ -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())
}
}