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
+10 -5
View File
@@ -167,11 +167,16 @@ type ActionDTO struct {
}
type WorklogDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Summary string `json:"summary"`
Minutes int `json:"minutes"`
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle,omitempty"`
Summary string `json:"summary"`
Minutes int `json:"minutes"`
Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
CreatedAt string `json:"createdAt"`
}
type SearchResultDTO struct {
+1
View File
@@ -19,6 +19,7 @@ func (a *App) ListSystemViews() []SystemViewDTO {
return []SystemViewDTO{
{ID: "today", Label: i18n.TF("ru", "nav.today")},
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
}
}
+167 -57
View File
@@ -4,19 +4,18 @@ import (
"fmt"
"sort"
"strings"
"time"
"verstak/internal/core/activity"
syncsvc "verstak/internal/core/sync"
)
// GetSuggestions analyzes today's activity and returns worklog suggestions.
// GetSuggestions analyzes today's activity and returns conservative suggestions.
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
events, err := a.activity.ListTodayEvents()
if err != nil {
if err != nil || len(events) == 0 {
return nil, err
}
if len(events) == 0 {
return nil, nil
}
type acc struct {
title string
@@ -50,37 +49,27 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
continue
}
noteCount := 0
fileCount := 0
actionCount := 0
otherCount := 0
for _, e := range grp.events {
switch e.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated, activity.TypeNoteDeleted:
noteCount++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed,
activity.TypeFileCopied, activity.TypeFileMoved,
activity.TypeFolderAdded, activity.TypeFolderDeleted, activity.TypeFolderRenamed:
fileCount++
case activity.TypeActionCreated, activity.TypeActionDone:
actionCount++
default:
otherCount++
}
}
summary := buildSuggestionSummary(noteCount, fileCount, actionCount, otherCount)
notes, files, actions, other := countByType(grp.events)
summary := buildSuggestionSummary(notes, files, actions, other)
if summary == "" {
continue
}
spread := timeSpread(grp.events)
bursts := countBursts(grp.events, 10)
min := estimateMinutes(bursts, spread, len(grp.events))
conf, reason := confidence(bursts, spread, len(grp.events))
suggestions = append(suggestions, activity.Suggestion{
NodeID: nodeID,
NodeTitle: grp.title,
Summary: summary,
SuggestedMin: suggestMinutes(noteCount + fileCount + actionCount + otherCount),
EventCount: len(grp.events),
NodeKind: grp.kind,
NodeID: nodeID,
NodeTitle: grp.title,
Summary: summary,
SuggestedMin: min,
EventCount: len(grp.events),
NodeKind: grp.kind,
Confidence: conf,
ConfidenceReason: reason,
TimeSpreadMin: spread,
})
}
@@ -91,39 +80,160 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
return suggestions, nil
}
// AcceptSuggestion creates a worklog entry from a suggestion.
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(s activity.Suggestion) (*WorklogDTO, error) {
return a.CreateWorklog(s.NodeID, s.Summary, s.SuggestedMin)
return a.AcceptSuggestionWith(s, s.SuggestedMin, "")
}
func buildSuggestionSummary(noteCount, fileCount, actionCount, otherCount int) string {
// AcceptSuggestionWith creates a worklog entry with optional overrides.
func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date string) (*WorklogDTO, error) {
d := date
if d == "" {
d = time.Now().Format("2006-01-02")
}
entry, err := a.worklog.AddWithDate(s.NodeID, s.Summary, "", d, minutes, true, false)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
return &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
Date: entry.Date,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
}
// HideSuggestion marks a suggestion as hidden for the session.
// The frontend tracks visibility; this is a no-op on the backend.
func (a *App) HideSuggestion(_ activity.Suggestion) error {
return nil
}
// --- event analysis ---
func countByType(events []activity.Event) (notes, files, actions, other int) {
for _, e := range events {
switch e.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated, activity.TypeNoteDeleted:
notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed,
activity.TypeFileCopied, activity.TypeFileMoved,
activity.TypeFolderAdded, activity.TypeFolderDeleted, activity.TypeFolderRenamed:
files++
case activity.TypeActionCreated, activity.TypeActionDone:
actions++
default:
other++
}
}
return
}
// timeSpread returns minutes between first and last event.
func timeSpread(events []activity.Event) int {
if len(events) < 2 {
return 0
}
minTime := events[0].CreatedAt
maxTime := events[0].CreatedAt
for _, e := range events {
if e.CreatedAt < minTime {
minTime = e.CreatedAt
}
if e.CreatedAt > maxTime {
maxTime = e.CreatedAt
}
}
t1, err1 := time.Parse(time.RFC3339, minTime)
t2, err2 := time.Parse(time.RFC3339, maxTime)
if err1 != nil || err2 != nil {
return 0
}
diff := t2.Sub(t1)
return int(diff.Minutes())
}
// countBursts groups events into bursts where consecutive events
// are within `windowMin` minutes of each other.
func countBursts(events []activity.Event, windowMin int) int {
if len(events) == 0 {
return 0
}
times := make([]time.Time, 0, len(events))
for _, e := range events {
t, err := time.Parse(time.RFC3339, e.CreatedAt)
if err != nil {
continue
}
times = append(times, t)
}
if len(times) == 0 {
return 1
}
sort.Slice(times, func(i, j int) bool { return times[i].Before(times[j]) })
bursts := 1
last := times[0]
for _, t := range times[1:] {
if t.Sub(last) > time.Duration(windowMin)*time.Minute {
bursts++
}
last = t
}
return bursts
}
// estimateMinutes conservatively estimates suggested minutes.
func estimateMinutes(bursts, spread, totalEvents int) int {
if totalEvents <= 1 {
return 5
}
switch {
case spread >= 60 && bursts >= 3 && totalEvents >= 8:
return 30
case spread >= 30 && bursts >= 2 && totalEvents >= 5:
return 20
case spread >= 15 && bursts >= 2 && totalEvents >= 3:
return 15
case totalEvents >= 3:
return 10
default:
return 5
}
}
// confidence returns a label and reason string for the estimate.
func confidence(bursts, spread, totalEvents int) (string, string) {
if spread >= 60 && totalEvents >= 10 {
return activity.ConfidenceHigh, fmt.Sprintf("активность растянута на %d минут, %d всплесков", spread, bursts)
}
if spread >= 30 && totalEvents >= 5 && bursts >= 2 {
return activity.ConfidenceMedium, fmt.Sprintf("несколько всплесков активности за %d минут", spread)
}
return activity.ConfidenceLow, fmt.Sprintf("%d событий за %d минут, %d всплесков", totalEvents, spread, bursts)
}
func buildSuggestionSummary(notes, files, actions, other int) string {
var parts []string
if noteCount > 0 {
parts = append(parts, fmt.Sprintf("заметки (%d)", noteCount))
if notes > 0 {
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
}
if fileCount > 0 {
parts = append(parts, fmt.Sprintf("файлы (%d)", fileCount))
if files > 0 {
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
}
if actionCount > 0 {
parts = append(parts, fmt.Sprintf("действия (%d)", actionCount))
if actions > 0 {
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
}
if otherCount > 0 {
parts = append(parts, fmt.Sprintf("события (%d)", otherCount))
if other > 0 {
parts = append(parts, fmt.Sprintf("события (%d)", other))
}
return strings.Join(parts, ", ")
}
func suggestMinutes(totalEvents int) int {
switch {
case totalEvents >= 15:
return 120
case totalEvents >= 10:
return 90
case totalEvents >= 6:
return 60
case totalEvents >= 3:
return 30
default:
return 15
}
}
+88 -24
View File
@@ -2,6 +2,7 @@ package main
import (
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
@@ -9,38 +10,101 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
if err != nil {
return nil, err
}
result := make([]WorklogDTO, len(list))
for i := range list {
mins := 0
if list[i].Minutes != nil {
mins = *list[i].Minutes
}
result[i] = WorklogDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Summary: list[i].Summary,
Minutes: mins,
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return result, nil
return toWorklogDTOs(list), nil
}
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
return a.CreateWorklogFull(nodeID, summary, "", "", minutes, false, false)
}
func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
if date == "" {
entry, err := a.worklog.Add(nodeID, summary, details, minutes, approximate, billable)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
return entryToDTO(entry), nil
}
entry, err := a.worklog.AddWithDate(nodeID, summary, details, date, minutes, approximate, billable)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
return entryToDTO(entry), nil
}
// --- report bindings ---
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool) ([]worklog.ReportRow, error) {
f := worklog.ReportFilter{
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
rows, err := a.worklog.ListReport(f)
if err != nil {
return nil, err
}
a.worklog.BuildReportPaths(rows)
return rows, nil
}
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool) (*worklog.ReportSummary, error) {
f := worklog.ReportFilter{
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
return a.worklog.Summary(f)
}
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
f := worklog.ReportFilter{
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
return a.worklog.ExportCSV(f)
}
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
f := worklog.ReportFilter{
DateFrom: dateFrom,
DateTo: dateTo,
NodeID: nodeID,
IncludeChildren: includeChildren,
}
return a.worklog.ExportMarkdown(f)
}
// --- helpers ---
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
result := make([]WorklogDTO, len(list))
for i := range list {
result[i] = *entryToDTO(&list[i])
}
return result
}
func entryToDTO(e *worklog.Entry) *WorklogDTO {
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
if e.Minutes != nil {
mins = *e.Minutes
}
return &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
ID: e.ID,
NodeID: e.NodeID,
Summary: e.Summary,
Minutes: mins,
Date: e.Date,
Details: e.Details,
Approximate: e.Approximate,
Billable: e.Billable,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-C6ZVnS_E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-8mzuSvQb.css">
<script type="module" crossorigin src="/assets/main-wlKdkTmp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
</head>
<body>
<div id="app"></div>