263 lines
7.0 KiB
Go
263 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"verstak/internal/core/activity"
|
|
syncsvc "verstak/internal/core/sync"
|
|
)
|
|
|
|
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
|
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|
events, err := a.activity.ListTodayEvents()
|
|
if err != nil || len(events) == 0 {
|
|
return nil, err
|
|
}
|
|
|
|
type acc struct {
|
|
title string
|
|
kind string
|
|
events []activity.Event
|
|
}
|
|
grouped := make(map[string]*acc)
|
|
for _, e := range events {
|
|
grp, ok := grouped[e.NodeID]
|
|
if !ok {
|
|
n, err := a.nodes.GetActive(e.NodeID)
|
|
title := ""
|
|
kind := ""
|
|
if err == nil && n != nil {
|
|
title = n.Title
|
|
kind = n.Type
|
|
}
|
|
grp = &acc{title: title, kind: kind}
|
|
grouped[e.NodeID] = grp
|
|
}
|
|
grp.events = append(grp.events, e)
|
|
}
|
|
|
|
var suggestions []activity.Suggestion
|
|
for nodeID, grp := range grouped {
|
|
if grp.title == "" {
|
|
continue
|
|
}
|
|
hasEntries, err := a.worklog.HasTodayEntries(nodeID)
|
|
if err != nil || hasEntries {
|
|
continue
|
|
}
|
|
|
|
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))
|
|
|
|
eventIDs := make([]string, 0, len(grp.events))
|
|
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
|
|
for _, e := range grp.events {
|
|
eventIDs = append(eventIDs, e.ID)
|
|
evDetails = append(evDetails, activity.SuggestionDetail{
|
|
ID: e.ID,
|
|
EventType: e.EventType,
|
|
TargetType: e.TargetType,
|
|
TargetID: e.TargetID,
|
|
Title: e.Title,
|
|
CreatedAt: e.CreatedAt,
|
|
NodeID: e.NodeID,
|
|
})
|
|
}
|
|
|
|
suggestions = append(suggestions, activity.Suggestion{
|
|
NodeID: nodeID,
|
|
NodeTitle: grp.title,
|
|
Summary: summary,
|
|
SuggestedMin: min,
|
|
EventCount: len(grp.events),
|
|
NodeKind: grp.kind,
|
|
Confidence: conf,
|
|
ConfidenceReason: reason,
|
|
TimeSpreadMin: spread,
|
|
EventIDs: eventIDs,
|
|
Events: evDetails,
|
|
})
|
|
}
|
|
|
|
sort.Slice(suggestions, func(i, j int) bool {
|
|
return suggestions[i].EventCount > suggestions[j].EventCount
|
|
})
|
|
|
|
return suggestions, nil
|
|
}
|
|
|
|
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
|
func (a *App) AcceptSuggestion(s activity.Suggestion) (*WorklogDTO, error) {
|
|
return a.AcceptSuggestionWith(s, s.SuggestedMin, "")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Link activity events to this worklog entry.
|
|
for _, eid := range s.EventIDs {
|
|
_, _ = a.db.Exec(
|
|
`INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
|
|
entry.ID, eid)
|
|
}
|
|
_ = 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 notes > 0 {
|
|
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
|
|
}
|
|
if files > 0 {
|
|
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
|
|
}
|
|
if actions > 0 {
|
|
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
|
|
}
|
|
if other > 0 {
|
|
parts = append(parts, fmt.Sprintf("события (%d)", other))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|