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:
2026-06-03 09:56:17 +08:00
parent 57d13c9506
commit c25e75f839
14 changed files with 1030 additions and 120 deletions
+17 -6
View File
@@ -1,11 +1,22 @@
package activity
// Confidence level for a suggestion.
const (
ConfidenceLow = "low"
ConfidenceMedium = "medium"
ConfidenceHigh = "high"
)
// Suggestion represents a suggested worklog entry derived from today's activity.
type Suggestion struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
Summary string `json:"summary"`
SuggestedMin int `json:"suggestedMin"`
EventCount int `json:"eventCount"`
NodeKind string `json:"nodeKind"`
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
Summary string `json:"summary"`
SuggestedMin int `json:"suggestedMin"`
EventCount int `json:"eventCount"`
NodeKind string `json:"nodeKind"`
Confidence string `json:"confidence"`
ConfidenceReason string `json:"confidenceReason"`
Hidden bool `json:"hidden"`
TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event
}
+398
View File
@@ -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{}
+1 -1
View File
@@ -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,