418 lines
11 KiB
Go
418 lines
11 KiB
Go
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
|
||
Section string // filter by node section (requires JOIN)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
sm := &ReportSummary{}
|
||
dayMap := make(map[string]int)
|
||
dayCount := make(map[string]int)
|
||
nodeMap := make(map[string]int)
|
||
nodeCount := make(map[string]int)
|
||
|
||
for _, r := range rows {
|
||
sm.TotalMinutes += r.Minutes
|
||
sm.TotalEntries++
|
||
dayMap[r.Date] += r.Minutes
|
||
dayCount[r.Date]++
|
||
nodeMap[r.NodeTitle] += r.Minutes
|
||
nodeCount[r.NodeTitle]++
|
||
}
|
||
|
||
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 node, min := range nodeMap {
|
||
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
|
||
}
|
||
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{}
|