Step 16.1: global worklog dashboard + conservative suggestions
- Fix date timezone: worklog.Add uses local date (was UTC) - Conservative suggestion estimator: - burst detection (10min window), time spread analysis - 5-30 min range, 60+ only with strong evidence - confidence levels: low/medium/high with reason - worklog/report.go: ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown - Expanded WorklogDTO: date, details, approximate, billable, nodeTitle - New bindings: CreateWorklogFull, ListWorklogReport, WorklogSummary, Export* - New system section 'Журнал' in sidebar with badge (suggestion count) - Global journal screen: filters (date range, includeChildren), table, summary - Suggestions shown on Today dashboard + Journal screen + per-node worklog tab - Suggestion cards: editable minutes, confidence display, apply/open buttons - i18n: all new keys in ru + en
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
package worklog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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]})
|
||||
}
|
||||
for node, min := range nodeMap {
|
||||
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
|
||||
}
|
||||
|
||||
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
|
||||
b.WriteString("Date,Node,Path,Summary,Minutes,Approximate,Billable,Created\n")
|
||||
for _, r := range rows {
|
||||
approx := "0"
|
||||
if r.Approximate {
|
||||
approx = "1"
|
||||
}
|
||||
bill := "0"
|
||||
if r.Billable {
|
||||
bill = "1"
|
||||
}
|
||||
summary := strings.ReplaceAll(r.Summary, "\"", "\"\"")
|
||||
b.WriteString(fmt.Sprintf("%s,\"%s\",\"%s\",\"%s\",%d,%s,%s,%s\n",
|
||||
r.Date, r.NodeTitle, r.NodePath, summary, r.Minutes, approx, bill, r.CreatedAt))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// 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",
|
||||
r.Date, r.NodeTitle, r.NodePath, 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", 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{}
|
||||
@@ -50,7 +50,7 @@ func (s *Service) Add(nodeID, summary, details string, minutes int, approximate,
|
||||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: time.Now().UTC().Format("2006-01-02"),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Minutes: &minutes,
|
||||
Approximate: approximate,
|
||||
Billable: billable,
|
||||
|
||||
Reference in New Issue
Block a user