package worklog import ( "encoding/csv" "fmt" "sort" "strings" "time" "verstak/internal/core/storage" ) // 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"` Source string `json:"source"` 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, COALESCE(w.source,'unknown'), 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, &r.Source, &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, COALESCE(w.source,'unknown'), 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, &r.Source, &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) { return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual) } // 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{}