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)) 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, }) } 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 } _ = 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, ", ") }