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:
+10
-5
@@ -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 {
|
||||
|
||||
@@ -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")},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user