verstak/cmd/verstak-gui/bindings_suggest.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, ", ")
}