feat: activity-based worklog suggestions (Step 16)
- Suggestion struct with nodeId, nodeTitle, summary, suggestedMin - GetSuggestions binding: analyzes today's activity events, groups by node, skips nodes with existing today's worklog, generates summary - AcceptSuggestion binding: creates worklog entry from suggestion - HasTodayEntries helper on worklog.Service - Suggestions panel in Worklog tab with Apply button - i18n: worklog.suggestions / worklog.apply (ru + en)
This commit is contained in:
parent
ca280a59c0
commit
57d13c9506
|
|
@ -0,0 +1,129 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"verstak/internal/core/activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSuggestions analyzes today's activity and returns worklog suggestions.
|
||||||
|
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
|
events, err := a.activity.ListTodayEvents()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(events) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if summary == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
func (a *App) AcceptSuggestion(s activity.Suggestion) (*WorklogDTO, error) {
|
||||||
|
return a.CreateWorklog(s.NodeID, s.Summary, s.SuggestedMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSuggestionSummary(noteCount, fileCount, actionCount, otherCount int) string {
|
||||||
|
var parts []string
|
||||||
|
if noteCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("заметки (%d)", noteCount))
|
||||||
|
}
|
||||||
|
if fileCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("файлы (%d)", fileCount))
|
||||||
|
}
|
||||||
|
if actionCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("действия (%d)", actionCount))
|
||||||
|
}
|
||||||
|
if otherCount > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("события (%d)", otherCount))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-hwPUi_6_.js"></script>
|
<script type="module" crossorigin src="/assets/main-C6ZVnS_E.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BBKDbfa7.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-8mzuSvQb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
let worklog = []
|
let worklog = []
|
||||||
let worklogMinutes = ''
|
let worklogMinutes = ''
|
||||||
let worklogSummary = ''
|
let worklogSummary = ''
|
||||||
|
let suggestions = []
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let createInNode = null
|
let createInNode = null
|
||||||
|
|
@ -169,6 +170,7 @@
|
||||||
files = []
|
files = []
|
||||||
actions = []
|
actions = []
|
||||||
worklog = []
|
worklog = []
|
||||||
|
suggestions = []
|
||||||
showCreateNode = false
|
showCreateNode = false
|
||||||
error = ''
|
error = ''
|
||||||
todayDashboard = null
|
todayDashboard = null
|
||||||
|
|
@ -197,6 +199,7 @@
|
||||||
files = []
|
files = []
|
||||||
actions = []
|
actions = []
|
||||||
worklog = []
|
worklog = []
|
||||||
|
suggestions = []
|
||||||
fileItems = []
|
fileItems = []
|
||||||
folderStack = []
|
folderStack = []
|
||||||
currentFolderId = null
|
currentFolderId = null
|
||||||
|
|
@ -217,6 +220,7 @@
|
||||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||||
|
try { suggestions = await wailsCall('GetSuggestions') || [] } catch(e) { suggestions = [] }
|
||||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -909,6 +913,18 @@
|
||||||
worklogMinutes = ''
|
worklogMinutes = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function acceptSuggestion(s) {
|
||||||
|
try {
|
||||||
|
await wailsCall('AcceptSuggestion', s)
|
||||||
|
if (selectedNode) {
|
||||||
|
try { worklog = await wailsCall('ListWorklog', selectedNode.id) || [] } catch(e) {}
|
||||||
|
try { suggestions = await wailsCall('GetSuggestions') || [] } catch(e) { suggestions = [] }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('accept suggestion', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Files =====
|
// ===== Files =====
|
||||||
async function addFile() {
|
async function addFile() {
|
||||||
const path = await wailsCall('PickFile')
|
const path = await wailsCall('PickFile')
|
||||||
|
|
@ -1505,6 +1521,22 @@
|
||||||
<button class="btn btn-primary" on:click={submitWorklog}
|
<button class="btn btn-primary" on:click={submitWorklog}
|
||||||
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
|
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
|
||||||
|
<div class="suggestions">
|
||||||
|
<div class="suggestions-title">{t('worklog.suggestions')}</div>
|
||||||
|
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
|
||||||
|
<div class="suggestion">
|
||||||
|
<div class="suggestion-body">
|
||||||
|
<div class="suggestion-summary">{s.summary}</div>
|
||||||
|
<div class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary" on:click={() => acceptSuggestion(s)}>
|
||||||
|
{t('worklog.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if worklog.length === 0}
|
{#if worklog.length === 0}
|
||||||
<div class="empty-state"><p>{t('worklog.empty')}</p></div>
|
<div class="empty-state"><p>{t('worklog.empty')}</p></div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -1997,6 +2029,13 @@
|
||||||
.worklog-form input[type="text"] { flex: 1; }
|
.worklog-form input[type="text"] { flex: 1; }
|
||||||
.worklog-form input[type="number"] { width: 70px; }
|
.worklog-form input[type="number"] { width: 70px; }
|
||||||
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
|
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
|
||||||
|
.suggestions { margin-bottom: 24px; padding: 16px; background: #1a1a2e; border-radius: 8px; border: 1px solid #2a2a3c; }
|
||||||
|
.suggestions-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.suggestion { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #2a2a3c; }
|
||||||
|
.suggestion:last-child { border-bottom: none; }
|
||||||
|
.suggestion-body { flex: 1; }
|
||||||
|
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
|
||||||
|
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
||||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
|
|
|
||||||
|
|
@ -120,4 +120,7 @@ export default {
|
||||||
|
|
||||||
'error.generic': 'An error occurred',
|
'error.generic': 'An error occurred',
|
||||||
'error.invalidCredentials': 'Invalid username or password',
|
'error.invalidCredentials': 'Invalid username or password',
|
||||||
|
|
||||||
|
'worklog.suggestions': 'Suggestions for today',
|
||||||
|
'worklog.apply': 'Apply',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,8 @@ export default {
|
||||||
'worklog.min': 'мин',
|
'worklog.min': 'мин',
|
||||||
'worklog.log': 'Записать',
|
'worklog.log': 'Записать',
|
||||||
'worklog.empty': 'Записей работы пока нет',
|
'worklog.empty': 'Записей работы пока нет',
|
||||||
|
'worklog.suggestions': 'Предложения на сегодня',
|
||||||
|
'worklog.apply': 'Применить',
|
||||||
|
|
||||||
'sync.title': 'Синхронизация',
|
'sync.title': 'Синхронизация',
|
||||||
'sync.settings': 'Настройки синхронизации',
|
'sync.settings': 'Настройки синхронизации',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package activity
|
||||||
|
|
||||||
|
// Suggestion represents a suggested worklog entry derived from today's activity.
|
||||||
|
type Suggestion struct {
|
||||||
|
NodeID string `json:"nodeId"`
|
||||||
|
NodeTitle string `json:"nodeTitle"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
SuggestedMin int `json:"suggestedMin"`
|
||||||
|
EventCount int `json:"eventCount"`
|
||||||
|
NodeKind string `json:"nodeKind"`
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,16 @@ func (s *Service) Delete(id string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasTodayEntries checks if any worklog entries exist for today.
|
||||||
|
func (s *Service) HasTodayEntries(nodeID string) (bool, error) {
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM worklog_entries WHERE node_id=? AND date=?`,
|
||||||
|
nodeID, today).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
// SumMinutes returns total minutes for a node.
|
// SumMinutes returns total minutes for a node.
|
||||||
func (s *Service) SumMinutes(nodeID string) (int, error) {
|
func (s *Service) SumMinutes(nodeID string) (int, error) {
|
||||||
var total sql.NullInt64
|
var total sql.NullInt64
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue