diff --git a/internal/server/report_handlers.go b/internal/server/report_handlers.go index cb6fdfd..5b7ddfd 100644 --- a/internal/server/report_handlers.go +++ b/internal/server/report_handlers.go @@ -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, }) } diff --git a/internal/server/report_handlers_test.go b/internal/server/report_handlers_test.go new file mode 100644 index 0000000..cc758a1 --- /dev/null +++ b/internal/server/report_handlers_test.go @@ -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) + } +} diff --git a/internal/ticket/report.go b/internal/ticket/report.go index dfb18a0..fed5246 100644 --- a/internal/ticket/report.go +++ b/internal/ticket/report.go @@ -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) +} diff --git a/internal/ticket/report_test.go b/internal/ticket/report_test.go index c71a042..910bcaa 100644 --- a/internal/ticket/report_test.go +++ b/internal/ticket/report_test.go @@ -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") + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index ad57ff1..87425e0 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -23,6 +23,9 @@ let msg = $state('') let report = $state(null) let reportGroupBy = $state('ticket') + let reportFrom = $state('') + let reportTo = $state('') + let reportTicketId = $state('') let dash = $state({ projects: [], openTickets: [] }) let tokens = $state([]) let newTokenSecret = $state('') @@ -180,7 +183,7 @@ } async function saveTimeEntry() { - if (!selected || !editingTimeId) return + if (!projectId || !editingTimeId) return if (!editTimeStart || !editTimeEnd) { error = 'Start and end are required' return @@ -197,7 +200,8 @@ end: end.toISOString(), }) cancelEditTime() - await refreshTicketTime() + if (projectView === 'report') await loadReport() + else await refreshTicketTime() msg = 'Time updated' } @@ -205,10 +209,25 @@ if (!confirm('Delete time entry?')) return await api.deleteTime(projectId, eid) if (editingTimeId === eid) cancelEditTime() - await refreshTicketTime() + if (projectView === 'report') await loadReport() + else await refreshTicketTime() msg = 'Time deleted' } + function ticketTitle(tid) { + const t = tickets.find((x) => x.id === tid) + return t?.title || tid || 'โ€”' + } + + function reportQuery() { + return { + groupBy: reportGroupBy, + from: reportFrom, + to: reportTo, + ticketId: reportTicketId, + } + } + async function doLogin() { error = '' try { @@ -414,11 +433,15 @@ await refreshTicketTime() } + async function loadReport() { + report = await api.report(projectId, reportQuery()) + } + async function showReport() { topTab = 'projects' projectView = 'report' error = '' - report = await api.report(projectId, reportGroupBy) + await loadReport() } async function openReport() { @@ -428,7 +451,27 @@ async function changeReportGroup(g) { reportGroupBy = g - report = await api.report(projectId, reportGroupBy) + await loadReport() + } + + async function applyReportFilters() { + error = '' + try { + await loadReport() + } catch (e) { + error = e.message || String(e) + } + } + + function clearReportFilters() { + reportFrom = '' + reportTo = '' + reportTicketId = '' + } + + async function resetReportFilters() { + clearReportFilters() + await applyReportFilters() } async function showProjectSettings() { @@ -826,7 +869,7 @@ {:else if topTab === 'projects' && projectView === 'report'}
-
+

Report โ€” {projectName}

{#each [['ticket', 'Ticket'], ['status', 'Status'], ['day', 'Day']] as [g, label]} @@ -834,8 +877,27 @@ {/each}
+
+ + + + + +
{#if report}

{report.entryCount} entries ยท total {formatDuration(report.totalDurationNs)}

+

Summary

@@ -846,6 +908,44 @@ {/each}
GroupDuration
+

Entries

+ + + + {#each report.entries || [] as e} + {#if editingTimeId === e.id} + + + + {:else} + + + + + + + + + {/if} + {:else} + + {/each} + +
WhenTicketDurationSourceNote
+
+ + + + +
+
{e.start ? new Date(e.start).toLocaleString() : 'โ€”'}{ticketTitle(e.ticketId)}{formatDuration(e.duration)}{e.source}{e.note || ''} + + +
No entries for this filter.
{/if}
{:else if topTab === 'projects' && projectView === 'settings'} diff --git a/web/src/lib/api.js b/web/src/lib/api.js index a2e4fec..5a2837b 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -71,8 +71,14 @@ export const api = { body: '{}', }), - report: (projectId, groupBy = 'ticket') => - request(`/projects/${projectId}/report?groupBy=${encodeURIComponent(groupBy)}`), + report: (projectId, { groupBy = 'ticket', from = '', to = '', ticketId = '' } = {}) => { + const q = new URLSearchParams() + q.set('groupBy', groupBy) + if (from) q.set('from', from) + if (to) q.set('to', to) + if (ticketId) q.set('ticketId', ticketId) + return request(`/projects/${projectId}/report?${q}`) + }, listTime: (projectId) => request(`/projects/${projectId}/time`), createTime: (projectId, body) => request(`/projects/${projectId}/time`, { method: 'POST', body: JSON.stringify(body) }),