verstak/internal/core/worklog/report.go

428 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package worklog
import (
"encoding/csv"
"fmt"
"sort"
"strings"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// ReportFilter specifies which worklog entries to include.
type ReportFilter struct {
DateFrom string // "2006-01-02" or "" for no lower bound
DateTo string // "2006-01-02" or "" for no upper bound
NodeID string // optional filter by node
IncludeChildren bool // include descendants of NodeID
Billable *bool // nil = all, true/false to filter
Approximate *bool // nil = all
}
// ReportRow is a single worklog entry with node info.
type ReportRow struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodePath string `json:"nodePath"`
Date string `json:"date"`
Summary string `json:"summary"`
Details string `json:"details"`
Minutes int `json:"minutes"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// SummaryGroup aggregates worklog minutes by a key.
type SummaryGroup struct {
Label string `json:"label"`
Minutes int `json:"minutes"`
Count int `json:"count"`
}
// ReportSummary contains aggregated totals.
type ReportSummary struct {
TotalMinutes int `json:"totalMinutes"`
TotalEntries int `json:"totalEntries"`
ByDay []SummaryGroup `json:"byDay"`
ByNode []SummaryGroup `json:"byNode"`
}
// nodeTitleAndParents returns title and full path for a node.
// Path is constructed by walking up parent_id chain.
func (s *Service) nodeTitleAndParents(nodeID string) (title, path string) {
type nodeRow struct {
id string
parentID *string
title string
}
// Walk up the chain.
currentID := nodeID
var chain []string
var nodeTitle string
seen := make(map[string]bool)
maxDepth := 10
for i := 0; i < maxDepth; i++ {
if currentID == "" || seen[currentID] {
break
}
seen[currentID] = true
var nr nodeRow
err := s.db.QueryRow(
`SELECT id, parent_id, title FROM nodes WHERE id = ?`, currentID,
).Scan(&nr.id, &nr.parentID, &nr.title)
if err != nil {
break
}
if nodeTitle == "" {
nodeTitle = nr.title
}
chain = append([]string{nr.title}, chain...)
if nr.parentID == nil {
break
}
currentID = *nr.parentID
}
return nodeTitle, strings.Join(chain, " > ")
}
// buildReportQuery constructs the SQL for a filtered worklog report.
func (s *Service) buildReportQuery(f ReportFilter) (string, []interface{}) {
var conditions []string
var args []interface{}
if f.DateFrom != "" {
conditions = append(conditions, "w.date >= ?")
args = append(args, f.DateFrom)
}
if f.DateTo != "" {
conditions = append(conditions, "w.date <= ?")
args = append(args, f.DateTo)
}
if f.Billable != nil {
conditions = append(conditions, "w.billable = ?")
v := 0
if *f.Billable {
v = 1
}
args = append(args, v)
}
if f.Approximate != nil {
conditions = append(conditions, "w.approximate = ?")
v := 0
if *f.Approximate {
v = 1
}
args = append(args, v)
}
if f.NodeID != "" {
if f.IncludeChildren {
conditions = append(conditions, `(w.node_id = ? OR w.node_id IN (
WITH RECURSIVE descs AS (
SELECT id FROM nodes WHERE parent_id = ?
UNION ALL
SELECT n.id FROM nodes n JOIN descs d ON n.parent_id = d.id
) SELECT id FROM descs
))`)
args = append(args, f.NodeID, f.NodeID)
} else {
conditions = append(conditions, "w.node_id = ?")
args = append(args, f.NodeID)
}
}
whereClause := ""
if len(conditions) > 0 {
whereClause = " WHERE " + strings.Join(conditions, " AND ")
}
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id` +
whereClause +
` ORDER BY w.date DESC, w.created_at DESC`
return q, args
}
// ListReport returns filtered worklog entries with node info.
func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
q, args := s.buildReportQuery(f)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReportRow
for rows.Next() {
var r ReportRow
var createdStr, updatedStr string
var approxInt, billInt int
err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr)
if err != nil {
return nil, err
}
r.Approximate = approxInt == 1
r.Billable = billInt == 1
r.CreatedAt = createdStr
r.UpdatedAt = updatedStr
// Build path lazily only if needed.
if r.NodeTitle == "" {
title, _ := s.nodeTitleAndParents(r.NodeID)
r.NodeTitle = title
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
s.BuildReportPaths(out)
return out, nil
}
// BuildReportPaths enriches report rows with node paths.
func (s *Service) BuildReportPaths(rows []ReportRow) {
pathCache := make(map[string]string, len(rows))
for i := range rows {
if rows[i].NodePath != "" {
continue
}
if p, ok := pathCache[rows[i].NodeID]; ok {
rows[i].NodePath = p
continue
}
_, path := s.nodeTitleAndParents(rows[i].NodeID)
pathCache[rows[i].NodeID] = path
rows[i].NodePath = path
}
}
// Summary aggregates worklog entries matching the filter.
func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
rows, err := s.ListReport(f)
if err != nil {
return nil, err
}
s.BuildReportPaths(rows)
sm := &ReportSummary{}
dayMap := make(map[string]int)
dayCount := make(map[string]int)
nodeMap := make(map[string]int)
nodeCount := make(map[string]int)
nodeLabel := make(map[string]string) // nodeID → NodePath
for _, r := range rows {
sm.TotalMinutes += r.Minutes
sm.TotalEntries++
dayMap[r.Date] += r.Minutes
dayCount[r.Date]++
nodeMap[r.NodeID] += r.Minutes
nodeCount[r.NodeID]++
label := r.NodePath
if label == "" {
label = r.NodeTitle
}
nodeLabel[r.NodeID] = label
}
for day, min := range dayMap {
sm.ByDay = append(sm.ByDay, SummaryGroup{Label: day, Minutes: min, Count: dayCount[day]})
}
sort.Slice(sm.ByDay, func(i, j int) bool {
return sm.ByDay[i].Label > sm.ByDay[j].Label // descending date
})
for nodeID, min := range nodeMap {
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: nodeLabel[nodeID], Minutes: min, Count: nodeCount[nodeID]})
}
sort.Slice(sm.ByNode, func(i, j int) bool {
if sm.ByNode[i].Minutes != sm.ByNode[j].Minutes {
return sm.ByNode[i].Minutes > sm.ByNode[j].Minutes
}
return sm.ByNode[i].Label < sm.ByNode[j].Label
})
return sm, nil
}
// ExportCSV returns a CSV string of the filtered report.
func (s *Service) ExportCSV(f ReportFilter) (string, error) {
rows, err := s.ListReport(f)
if err != nil {
return "", err
}
s.BuildReportPaths(rows)
var b strings.Builder
w := csv.NewWriter(&b)
w.Write([]string{"Date", "Node", "Path", "Summary", "Minutes", "Approximate", "Billable", "Created"})
for _, r := range rows {
approx := "0"
if r.Approximate {
approx = "1"
}
bill := "0"
if r.Billable {
bill = "1"
}
w.Write([]string{r.Date, r.NodeTitle, r.NodePath, r.Summary,
fmt.Sprintf("%d", r.Minutes), approx, bill, r.CreatedAt})
}
w.Flush()
return b.String(), w.Error()
}
func escMD(s string) string {
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
return s
}
// ExportMarkdown returns a Markdown report.
func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
rows, err := s.ListReport(f)
if err != nil {
return "", err
}
s.BuildReportPaths(rows)
sm, _ := s.Summary(f)
var b strings.Builder
b.WriteString("# Worklog Report\n\n")
period := ""
if f.DateFrom != "" || f.DateTo != "" {
period = fmt.Sprintf(" (%s %s)", f.DateFrom, f.DateTo)
}
b.WriteString(fmt.Sprintf("**Period:**%s\n\n", period))
if sm != nil {
b.WriteString(fmt.Sprintf("**Total:** %dh %dm (%d entries)\n\n",
sm.TotalMinutes/60, sm.TotalMinutes%60, sm.TotalEntries))
}
b.WriteString("| Date | Node | Path | Summary | Minutes |\n")
b.WriteString("|------|------|------|---------|--------|\n")
for _, r := range rows {
approx := ""
if r.Approximate {
approx = " ~"
}
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %d%s |\n",
escMD(r.Date), escMD(r.NodeTitle), escMD(r.NodePath), escMD(r.Summary), r.Minutes, approx))
}
if sm != nil {
b.WriteString("\n## Summary\n\n")
b.WriteString(fmt.Sprintf("**Total:** %dh %dm\n\n", sm.TotalMinutes/60, sm.TotalMinutes%60))
if len(sm.ByDay) > 0 {
b.WriteString("### By Day\n\n")
b.WriteString("| Date | Minutes | Entries |\n")
b.WriteString("|------|---------|--------|\n")
for _, d := range sm.ByDay {
b.WriteString(fmt.Sprintf("| %s | %dh %dm | %d |\n", escMD(d.Label), d.Minutes/60, d.Minutes%60, d.Count))
}
b.WriteString("\n")
}
}
return b.String(), nil
}
// GetByIDWithNode returns a single entry with node title.
func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id
WHERE w.id = ?`
var r ReportRow
var createdStr, updatedStr string
var approxInt, billInt int
err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr)
if err != nil {
return nil, err
}
r.Approximate = approxInt == 1
r.Billable = billInt == 1
r.CreatedAt = createdStr
r.UpdatedAt = updatedStr
return &r, nil
}
// AddWithDate inserts a worklog entry with a specific date.
func (s *Service) AddWithDate(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*Entry, error) {
if nodeID == "" {
return nil, fmt.Errorf("node_id required")
}
if summary == "" {
return nil, fmt.Errorf("summary required")
}
if date == "" {
date = time.Now().Format("2006-01-02")
}
e := &Entry{
ID: util.UUID7(),
NodeID: nodeID,
Summary: summary,
Details: details,
Date: date,
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return nil, err
}
return e, nil
}
// UpdateDate updates the date of an entry.
func (s *Service) UpdateDate(id, date string) error {
t := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE worklog_entries SET date=?, updated_at=? WHERE id=?`,
date, t, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("entry not found")
}
return nil
}
var _ = storage.DB{}