Add date and ticket filters plus entry list to the project time report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 23:23:22 +01:00
co-authored by Cursor
parent 2061df63d4
commit 34ab3b9c5b
6 changed files with 368 additions and 8 deletions
+40
View File
@@ -2,6 +2,7 @@ package server
import (
"net/http"
"sort"
"task/internal/sqlite"
"task/internal/ticket"
@@ -29,11 +30,46 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
return
}
from, err := ticket.ParseReportDay(r.URL.Query().Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
toDay, err := ticket.ParseReportDay(r.URL.Query().Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var toBound = toDay
if !toDay.IsZero() {
toBound = ticket.EndOfReportDay(toDay)
}
filter := ticket.TimeFilter{
From: from,
To: toBound,
TicketID: r.URL.Query().Get("ticketId"),
}
entries, err := s.Times.ListByProject(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
entries = ticket.FilterEntries(entries, filter)
sort.Slice(entries, func(i, j int) bool {
ai, aj := entries[i].Start, entries[j].Start
if ai.IsZero() {
ai = entries[i].End
}
if aj.IsZero() {
aj = entries[j].End
}
if !ai.Equal(aj) {
return ai.After(aj)
}
return entries[i].ID > entries[j].ID
})
tickets, err := s.Tickets.List(id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -64,5 +100,9 @@ func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
"totalDurationNs": int64(total),
"rows": out,
"entryCount": len(entries),
"entries": entries,
"from": r.URL.Query().Get("from"),
"to": r.URL.Query().Get("to"),
"ticketId": filter.TicketID,
})
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"task/internal/sqlite"
"task/internal/ticket"
)
func TestProjectReportFilters(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)
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)
var tk map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &tk)
tid, _ := tk["id"].(string)
day := time.Date(2026, 10, 18, 14, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: day,
End: day.Add(45 * time.Minute),
Duration: 45 * time.Minute,
Source: ticket.SourceManual,
Note: "filtered",
})
if err != nil {
t.Fatal(err)
}
other := time.Date(2026, 9, 1, 10, 0, 0, 0, time.Local)
_, err = srv.Times.Create(ticket.TimeEntry{
TicketID: tid,
ProjectID: pid,
Start: other,
End: other.Add(time.Hour),
Duration: time.Hour,
Source: ticket.SourceManual,
})
if err != nil {
t.Fatal(err)
}
url := "/api/v1/projects/" + pid + "/report?groupBy=ticket&from=2026-10-18&to=2026-10-18&ticketId=" + tid
req = httptest.NewRequest(http.MethodGet, url, nil)
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report %d %s", rec.Code, rec.Body.String())
}
var out struct {
EntryCount int `json:"entryCount"`
TotalDurationNs int64 `json:"totalDurationNs"`
Entries []ticket.TimeEntry `json:"entries"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.EntryCount != 1 || len(out.Entries) != 1 {
t.Fatalf("want 1 entry, got count=%d entries=%d", out.EntryCount, len(out.Entries))
}
if out.TotalDurationNs != int64(45*time.Minute) {
t.Fatalf("total=%d", out.TotalDurationNs)
}
if out.Entries[0].Note != "filtered" {
t.Fatalf("note=%q", out.Entries[0].Note)
}
}
+60
View File
@@ -3,6 +3,7 @@ package ticket
import (
"fmt"
"sort"
"strings"
"time"
)
@@ -155,3 +156,62 @@ func TotalDuration(entries []TimeEntry) time.Duration {
}
return sum
}
// TimeFilter selects which time entries belong in a report.
// From/To are inclusive bounds on the entry's work instant (Start, else End).
// Zero times mean no bound on that side.
type TimeFilter struct {
From time.Time
To time.Time
TicketID string
}
// FilterEntries returns entries matching f. The input slice is not modified.
func FilterEntries(entries []TimeEntry, f TimeFilter) []TimeEntry {
if f.From.IsZero() && f.To.IsZero() && f.TicketID == "" {
out := make([]TimeEntry, len(entries))
copy(out, entries)
return out
}
out := make([]TimeEntry, 0, len(entries))
for _, e := range entries {
if f.TicketID != "" && e.TicketID != f.TicketID {
continue
}
when := e.Start
if when.IsZero() {
when = e.End
}
if !f.From.IsZero() && (when.IsZero() || when.Before(f.From)) {
continue
}
if !f.To.IsZero() && (when.IsZero() || when.After(f.To)) {
continue
}
out = append(out, e)
}
return out
}
// ParseReportDay parses YYYY-MM-DD as local midnight. Empty returns zero time.
func ParseReportDay(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, nil
}
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
if err != nil {
return time.Time{}, fmt.Errorf("invalid date %q", s)
}
return t, nil
}
// EndOfReportDay returns the last instant of the calendar day of t in local time.
func EndOfReportDay(t time.Time) time.Time {
if t.IsZero() {
return t
}
local := t.In(time.Local)
y, m, d := local.Date()
return time.Date(y, m, d, 23, 59, 59, 999999999, time.Local)
}
+45
View File
@@ -70,3 +70,48 @@ func TestAggregateByDay(t *testing.T) {
t.Fatalf("older day dur=%v", rows[1].Duration)
}
}
func TestFilterEntries(t *testing.T) {
d1 := time.Date(2026, 10, 18, 10, 0, 0, 0, time.Local)
d2 := time.Date(2026, 10, 20, 11, 0, 0, 0, time.Local)
entries := []ticket.TimeEntry{
{ID: "1", TicketID: "a", Start: d1, Duration: time.Hour},
{ID: "2", TicketID: "b", Start: d2, Duration: 30 * time.Minute},
{ID: "3", TicketID: "a", Start: d2, Duration: 15 * time.Minute},
}
byTicket := ticket.FilterEntries(entries, ticket.TimeFilter{TicketID: "a"})
if len(byTicket) != 2 {
t.Fatalf("ticket filter=%d", len(byTicket))
}
from := time.Date(2026, 10, 19, 0, 0, 0, 0, time.Local)
to := ticket.EndOfReportDay(time.Date(2026, 10, 20, 0, 0, 0, 0, time.Local))
byRange := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to})
if len(byRange) != 2 {
t.Fatalf("range filter=%d", len(byRange))
}
both := ticket.FilterEntries(entries, ticket.TimeFilter{From: from, To: to, TicketID: "a"})
if len(both) != 1 || both[0].ID != "3" {
t.Fatalf("combined=%+v", both)
}
}
func TestParseReportDay(t *testing.T) {
got, err := ticket.ParseReportDay("2026-10-18")
if err != nil {
t.Fatal(err)
}
y, m, d := got.Date()
if y != 2026 || m != 10 || d != 18 || got.Hour() != 0 {
t.Fatalf("got %v", got)
}
empty, err := ticket.ParseReportDay("")
if err != nil || !empty.IsZero() {
t.Fatalf("empty: %v %v", empty, err)
}
if _, err := ticket.ParseReportDay("nope"); err == nil {
t.Fatal("expected error")
}
}