package main import ( "fmt" "sort" "strings" "time" "verstak/internal/core/activity" "verstak/internal/core/worklog" syncsvc "verstak/internal/core/sync" ) // GetSuggestions analyzes today's activity and returns conservative suggestions. // Only events not already linked in worklog_entry_events are considered. func (a *App) GetSuggestions() ([]activity.Suggestion, error) { events, err := a.activity.ListTodayEvents() if err != nil || len(events) == 0 { return nil, err } // Determine which event IDs are already accounted for in any worklog entry. accounted := make(map[string]bool) rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`) if err == nil { defer rows.Close() for rows.Next() { var eid string if rows.Scan(&eid) == nil { accounted[eid] = true } } } type acc struct { title string kind string events []activity.Event } grouped := make(map[string]*acc) for _, e := range events { if accounted[e.ID] { continue } 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 == "" || len(grp.events) == 0 { 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, uses flat fields). func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDs []string) (*WorklogDTO, error) { return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDs) } // AcceptSuggestionWith creates a worklog entry and links events. Uses flat fields to avoid Wails marshalling issues. func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDs []string) (*WorklogDTO, error) { d := date if d == "" { d = time.Now().Format("2006-01-02") } entry, err := a.worklog.AddWithSource(nodeID, summary, "", d, minutes, true, false, worklog.SourceSuggestion) if err != nil { return nil, err } // Link activity events to this worklog entry. for _, eid := range eventIDs { _, err := a.db.Exec( `INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`, entry.ID, eid) if err != nil { return nil, fmt.Errorf("link event %s: %w", eid, 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, ", ") }