Step 16.1: global worklog dashboard + conservative suggestions
- Fix date timezone: worklog.Add uses local date (was UTC) - Conservative suggestion estimator: - burst detection (10min window), time spread analysis - 5-30 min range, 60+ only with strong evidence - confidence levels: low/medium/high with reason - worklog/report.go: ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown - Expanded WorklogDTO: date, details, approximate, billable, nodeTitle - New bindings: CreateWorklogFull, ListWorklogReport, WorklogSummary, Export* - New system section 'Журнал' in sidebar with badge (suggestion count) - Global journal screen: filters (date range, includeChildren), table, summary - Suggestions shown on Today dashboard + Journal screen + per-node worklog tab - Suggestion cards: editable minutes, confidence display, apply/open buttons - i18n: all new keys in ru + en
This commit is contained in:
parent
57d13c9506
commit
c25e75f839
|
|
@ -167,11 +167,16 @@ type ActionDTO struct {
|
|||
}
|
||||
|
||||
type WorklogDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type SearchResultDTO struct {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ func (a *App) ListSystemViews() []SystemViewDTO {
|
|||
return []SystemViewDTO{
|
||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
|
||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,19 +4,18 @@ import (
|
|||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/activity"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
)
|
||||
|
||||
// GetSuggestions analyzes today's activity and returns worklog suggestions.
|
||||
// GetSuggestions analyzes today's activity and returns conservative suggestions.
|
||||
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||
events, err := a.activity.ListTodayEvents()
|
||||
if err != nil {
|
||||
if err != nil || len(events) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type acc struct {
|
||||
title string
|
||||
|
|
@ -50,37 +49,27 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
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)
|
||||
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: suggestMinutes(noteCount + fileCount + actionCount + otherCount),
|
||||
EventCount: len(grp.events),
|
||||
NodeKind: grp.kind,
|
||||
NodeID: nodeID,
|
||||
NodeTitle: grp.title,
|
||||
Summary: summary,
|
||||
SuggestedMin: min,
|
||||
EventCount: len(grp.events),
|
||||
NodeKind: grp.kind,
|
||||
Confidence: conf,
|
||||
ConfidenceReason: reason,
|
||||
TimeSpreadMin: spread,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -91,39 +80,160 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
return suggestions, nil
|
||||
}
|
||||
|
||||
// AcceptSuggestion creates a worklog entry from a suggestion.
|
||||
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
||||
func (a *App) AcceptSuggestion(s activity.Suggestion) (*WorklogDTO, error) {
|
||||
return a.CreateWorklog(s.NodeID, s.Summary, s.SuggestedMin)
|
||||
return a.AcceptSuggestionWith(s, s.SuggestedMin, "")
|
||||
}
|
||||
|
||||
func buildSuggestionSummary(noteCount, fileCount, actionCount, otherCount int) string {
|
||||
// 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 noteCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("заметки (%d)", noteCount))
|
||||
if notes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
|
||||
}
|
||||
if fileCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("файлы (%d)", fileCount))
|
||||
if files > 0 {
|
||||
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
|
||||
}
|
||||
if actionCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("действия (%d)", actionCount))
|
||||
if actions > 0 {
|
||||
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
|
||||
}
|
||||
if otherCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("события (%d)", otherCount))
|
||||
if other > 0 {
|
||||
parts = append(parts, fmt.Sprintf("события (%d)", other))
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||
|
|
@ -9,38 +10,101 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]WorklogDTO, len(list))
|
||||
for i := range list {
|
||||
mins := 0
|
||||
if list[i].Minutes != nil {
|
||||
mins = *list[i].Minutes
|
||||
}
|
||||
result[i] = WorklogDTO{
|
||||
ID: list[i].ID,
|
||||
NodeID: list[i].NodeID,
|
||||
Summary: list[i].Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return toWorklogDTOs(list), nil
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
|
||||
return a.CreateWorklogFull(nodeID, summary, "", "", minutes, false, false)
|
||||
}
|
||||
|
||||
func (a *App) CreateWorklogFull(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*WorklogDTO, error) {
|
||||
if date == "" {
|
||||
entry, err := a.worklog.Add(nodeID, summary, details, minutes, approximate, billable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
entry, err := a.worklog.AddWithDate(nodeID, summary, details, date, minutes, approximate, billable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
||||
return entryToDTO(entry), nil
|
||||
}
|
||||
|
||||
// --- report bindings ---
|
||||
|
||||
func (a *App) ListWorklogReport(dateFrom, dateTo, nodeID string, includeChildren bool) ([]worklog.ReportRow, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
rows, err := a.worklog.ListReport(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.worklog.BuildReportPaths(rows)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) WorklogReportSummary(dateFrom, dateTo, nodeID string, includeChildren bool) (*worklog.ReportSummary, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
return a.worklog.Summary(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogCSV(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
return a.worklog.ExportCSV(f)
|
||||
}
|
||||
|
||||
func (a *App) ExportWorklogMarkdown(dateFrom, dateTo, nodeID string, includeChildren bool) (string, error) {
|
||||
f := worklog.ReportFilter{
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
NodeID: nodeID,
|
||||
IncludeChildren: includeChildren,
|
||||
}
|
||||
return a.worklog.ExportMarkdown(f)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {
|
||||
result := make([]WorklogDTO, len(list))
|
||||
for i := range list {
|
||||
result[i] = *entryToDTO(&list[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
||||
mins := 0
|
||||
if entry.Minutes != nil {
|
||||
mins = *entry.Minutes
|
||||
if e.Minutes != nil {
|
||||
mins = *e.Minutes
|
||||
}
|
||||
return &WorklogDTO{
|
||||
ID: entry.ID,
|
||||
NodeID: entry.NodeID,
|
||||
Summary: entry.Summary,
|
||||
Minutes: mins,
|
||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
ID: e.ID,
|
||||
NodeID: e.NodeID,
|
||||
Summary: e.Summary,
|
||||
Minutes: mins,
|
||||
Date: e.Date,
|
||||
Details: e.Details,
|
||||
Approximate: e.Approximate,
|
||||
Billable: e.Billable,
|
||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-C6ZVnS_E.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-8mzuSvQb.css">
|
||||
<script type="module" crossorigin src="/assets/main-wlKdkTmp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
28
docs/PLAN.md
28
docs/PLAN.md
|
|
@ -25,8 +25,8 @@
|
|||
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||
| 14 | **MVP stabilization** | ✅ выполнено |
|
||||
| 15 | **Sync Server + Client** | 🔄 в работе — HTTP API, push/pull, blob sync |
|
||||
| 16 | **Activity Suggestions** | ⏳ ожидает — подсказки из activity_events |
|
||||
| 15 | **Sync Server + Client** | ✅ выполнено |
|
||||
| 16 | **Activity Suggestions** | 🔄 в работе — global worklog dashboard + conservative suggestions + UX |
|
||||
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
||||
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
||||
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
|
||||
|
|
@ -167,15 +167,25 @@ Core service extensions:
|
|||
|
||||
---
|
||||
|
||||
## Текущий этап: ШАГ 15 — Sync Server + Client
|
||||
## Текущий этап: ШАГ 16 — Activity Suggestions
|
||||
|
||||
**Статус:** ✅ Сервер собран и работает. Клиент реализован. Нужна доработка и тестирование.
|
||||
**Статус:** 🔄 в работе — activity-based suggestions + global worklog dashboard + conservative estimator.
|
||||
|
||||
Стек синхронизации:
|
||||
- `cmd/verstak-server/` — HTTP API сервер (порт, API key, push/pull)
|
||||
- `internal/core/sync/` — клиент синхронизации
|
||||
- SQLite-based sync state (`migrations/008_sync.sql`)
|
||||
- FS-first apply: изменения применяются через файловую систему, БД — индекс
|
||||
Что реализовано:
|
||||
- `suggest.go` — структура Suggestion (nodeId, nodeTitle, summary, suggestedMin, confidence)
|
||||
- `bindings_suggest.go` — GetSuggestions с burst detection + conservative estimator (5–30 min)
|
||||
- `bindings_suggest.go` — AcceptSuggestionWith (date/minutes overrides)
|
||||
- `worklog/report.go` — ReportFilter, ListReport, Summary, ExportCSV, ExportMarkdown
|
||||
- `app.go` — WorklogDTO расширен: date, details, approximate, billable, nodeTitle
|
||||
- `bindings_worklog.go` — CreateWorklogFull, ListWorklogReport, WorklogSummary, Export*
|
||||
- Новый системный раздел "Журнал" с таблицей, фильтрами (даты, includeChildren), сводкой
|
||||
- Предложения на экране "Сегодня" + вкладке "Журнал" + вкладке "Журнал дела"
|
||||
- Badge с количеством предложений в боковом меню
|
||||
- Консервативный estimator:
|
||||
- burst detection (группировка событий в окне 10 мин)
|
||||
- time spread analysis
|
||||
- 5–30 мин, 60+ только при сильном evidence
|
||||
- Confidence: low/medium/high + reason
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@
|
|||
let activityFeed = []
|
||||
let activityOffset = 0
|
||||
let activityHasMore = true
|
||||
let journalRows = []
|
||||
let journalSummary = null
|
||||
let journalDateFrom = ''
|
||||
let journalDateTo = ''
|
||||
let journalIncludeChildren = false
|
||||
let activityLoading = false
|
||||
let caseActivity = []
|
||||
let version = ''
|
||||
|
|
@ -49,6 +54,7 @@
|
|||
let worklogMinutes = ''
|
||||
let worklogSummary = ''
|
||||
let suggestions = []
|
||||
let suggestionCount = 0
|
||||
let showCreateNode = false
|
||||
let newNodeTitle = ''
|
||||
let createInNode = null
|
||||
|
|
@ -177,9 +183,15 @@
|
|||
activityFeed = []
|
||||
activityOffset = 0
|
||||
activityHasMore = true
|
||||
journalRows = []
|
||||
journalSummary = null
|
||||
try {
|
||||
if (id === 'today') {
|
||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
} else if (id === 'journal') {
|
||||
await loadJournal()
|
||||
} else if (id === 'activity') {
|
||||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||
activityOffset = activityFeed.length
|
||||
|
|
@ -220,7 +232,10 @@
|
|||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||
try { suggestions = await wailsCall('GetSuggestions') || [] } catch(e) { suggestions = [] }
|
||||
try {
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
} catch(e) { suggestions = []; suggestionCount = 0 }
|
||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||
}
|
||||
|
||||
|
|
@ -913,18 +928,84 @@
|
|||
worklogMinutes = ''
|
||||
}
|
||||
|
||||
async function acceptTodaySuggestion(s) {
|
||||
try {
|
||||
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
if (selectedNode) {
|
||||
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function acceptJournalSuggestion(s) {
|
||||
try {
|
||||
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
await loadJournal()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function acceptSuggestion(s) {
|
||||
try {
|
||||
await wailsCall('AcceptSuggestion', s)
|
||||
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||
suggestions = await wailsCall('GetSuggestions') || []
|
||||
suggestionCount = suggestions.length
|
||||
if (selectedNode) {
|
||||
try { worklog = await wailsCall('ListWorklog', selectedNode.id) || [] } catch(e) {}
|
||||
try { suggestions = await wailsCall('GetSuggestions') || [] } catch(e) { suggestions = [] }
|
||||
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('accept suggestion', e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Journal =====
|
||||
async function loadJournal() {
|
||||
try {
|
||||
const [rows, summary, sugs] = await Promise.all([
|
||||
wailsCall('ListWorklogReport', journalDateFrom, journalDateTo, '', journalIncludeChildren),
|
||||
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, '', journalIncludeChildren),
|
||||
wailsCall('GetSuggestions'),
|
||||
])
|
||||
journalRows = rows || []
|
||||
journalSummary = summary || null
|
||||
suggestions = sugs || []
|
||||
suggestionCount = suggestions.length
|
||||
} catch (e) {
|
||||
journalRows = []
|
||||
journalSummary = null
|
||||
suggestions = []
|
||||
suggestionCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJournalCSV() {
|
||||
try {
|
||||
const csv = await wailsCall('ExportWorklogCSV', journalDateFrom, journalDateTo, '', journalIncludeChildren)
|
||||
downloadFile('worklog.csv', csv, 'text/csv')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function exportJournalMarkdown() {
|
||||
try {
|
||||
const md = await wailsCall('ExportWorklogMarkdown', journalDateFrom, journalDateTo, '', journalIncludeChildren)
|
||||
downloadFile('worklog.md', md, 'text/markdown')
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
function downloadFile(name, content, mime) {
|
||||
const blob = new Blob([content], { type: mime })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ===== Files =====
|
||||
async function addFile() {
|
||||
const path = await wailsCall('PickFile')
|
||||
|
|
@ -1220,6 +1301,9 @@
|
|||
<button class="nav-item {selectedSection === view.id ? 'selected' : ''}"
|
||||
on:click={() => selectSystemView(view.id)}>
|
||||
{view.label}
|
||||
{#if view.id === 'journal' && suggestionCount > 0}
|
||||
<span class="nav-badge">{suggestionCount}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1522,17 +1606,19 @@
|
|||
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
|
||||
</div>
|
||||
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
|
||||
<div class="suggestions">
|
||||
<div class="worklog-tab-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 class="suggestion-card">
|
||||
<div class="suggestion-info">
|
||||
<span class="suggestion-summary">{s.summary}</span>
|
||||
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
|
||||
</div>
|
||||
<div class="suggestion-actions">
|
||||
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>
|
||||
{t('worklog.apply')}
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" on:click={() => acceptSuggestion(s)}>
|
||||
{t('worklog.apply')}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -1570,6 +1656,104 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'journal'}
|
||||
<div class="journal-screen">
|
||||
<div class="journal-header">
|
||||
<h2>{t('journal.title')}</h2>
|
||||
<div class="journal-filters">
|
||||
<label><span class="label-text">{t('journal.dateFrom')}</span>
|
||||
<input type="date" bind:value={journalDateFrom} />
|
||||
</label>
|
||||
<label><span class="label-text">{t('journal.dateTo')}</span>
|
||||
<input type="date" bind:value={journalDateTo} />
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" bind:checked={journalIncludeChildren} />
|
||||
<span>{t('journal.includeChildren')}</span>
|
||||
</label>
|
||||
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
|
||||
<button class="btn btn-sm" on:click={exportJournalCSV}>{t('journal.exportCSV')}</button>
|
||||
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if suggestions.length > 0}
|
||||
<div class="journal-suggestions">
|
||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
||||
{#each suggestions as s}
|
||||
<div class="suggestion-card">
|
||||
<div class="suggestion-info">
|
||||
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
||||
<span class="suggestion-summary">{s.summary}</span>
|
||||
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
||||
</div>
|
||||
<div class="suggestion-actions">
|
||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||
<button class="btn btn-sm btn-primary" on:click={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if journalSummary}
|
||||
<div class="journal-summary">
|
||||
<div class="summary-total">{t('journal.total')}: {Math.floor(journalSummary.totalMinutes / 60)}ч {journalSummary.totalMinutes % 60}м ({journalSummary.totalEntries} {t('worklog.min')})</div>
|
||||
{#if journalSummary.byDay && journalSummary.byDay.length > 0}
|
||||
<div class="summary-section">
|
||||
<div class="summary-label">{t('journal.byDay')}</div>
|
||||
{#each journalSummary.byDay as g}
|
||||
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if journalSummary.byNode && journalSummary.byNode.length > 0}
|
||||
<div class="summary-section">
|
||||
<div class="summary-label">{t('journal.byNode')}</div>
|
||||
{#each journalSummary.byNode as g}
|
||||
<div class="summary-row"><span>{g.label}</span><span>{Math.floor(g.minutes / 60)}ч {g.minutes % 60}м</span><span class="summary-count">{g.count}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if journalRows.length === 0}
|
||||
<div class="empty-state"><p>{t('journal.empty')}</p></div>
|
||||
{:else}
|
||||
<div class="journal-table-wrap">
|
||||
<table class="journal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('journal.title')}</th>
|
||||
<th>{t('journal.node')}</th>
|
||||
<th>{t('journal.path')}</th>
|
||||
<th>{t('worklog.minutes')}</th>
|
||||
<th>{t('journal.billable')}</th>
|
||||
<th>{t('journal.approximate')}</th>
|
||||
<th>{t('common.date')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each journalRows as r}
|
||||
<tr>
|
||||
<td>{r.summary}</td>
|
||||
<td><button class="link-btn" on:click={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
|
||||
<td class="journal-path-cell">{r.nodePath}</td>
|
||||
<td class="journal-min-cell">{r.minutes}</td>
|
||||
<td>{#if r.billable}✓{/if}</td>
|
||||
<td>{#if r.approximate}~{/if}</td>
|
||||
<td class="journal-date-cell">{r.date}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'today' && todayDashboard}
|
||||
<div class="today-dashboard">
|
||||
<div class="today-header">
|
||||
|
|
@ -1584,6 +1768,27 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if suggestions.length > 0}
|
||||
<div class="today-suggestions">
|
||||
<div class="suggestions-title">{t('suggest.title')}</div>
|
||||
{#each suggestions as s}
|
||||
<div class="suggestion-card">
|
||||
<div class="suggestion-info">
|
||||
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
|
||||
<span class="suggestion-summary">{s.summary}</span>
|
||||
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
|
||||
</div>
|
||||
<div class="suggestion-actions">
|
||||
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
|
||||
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} />
|
||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||||
{#each todayDashboard.groups as group}
|
||||
<div class="today-case">
|
||||
|
|
@ -2029,13 +2234,58 @@
|
|||
.worklog-form input[type="text"] { flex: 1; }
|
||||
.worklog-form input[type="number"] { width: 70px; }
|
||||
.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; }
|
||||
|
||||
/* Suggestion cards */
|
||||
.suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; }
|
||||
.suggestion-card:last-child { margin-bottom: 0; }
|
||||
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
|
||||
.suggestion-node:hover { text-decoration: underline; }
|
||||
.suggestion-actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.suggestion-min-input { width: 60px; padding: 4px 8px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; text-align: center; }
|
||||
.suggestion-min-label { font-size: 12px; color: #8888a0; }
|
||||
.suggestion-confidence { font-size: 11px; padding: 2px 6px; border-radius: 3px; }
|
||||
.suggestion-confidence.low { color: #fbbf24; }
|
||||
.suggestion-confidence.medium { color: #60a5fa; }
|
||||
.suggestion-confidence.high { color: #34d399; }
|
||||
|
||||
/* Journal screen */
|
||||
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.journal-header { margin-bottom: 24px; }
|
||||
.journal-header h2 { margin: 0 0 16px 0; }
|
||||
.journal-filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.journal-filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8888a0; }
|
||||
.journal-filters input[type="date"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; }
|
||||
.journal-filters .checkbox-label { flex-direction: row; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.journal-filters .checkbox-label input { width: auto; }
|
||||
.journal-summary { display: flex; flex-wrap: wrap; gap: 24px; margin-bottom: 24px; padding: 16px; background: #1a1a2e; border-radius: 8px; border: 1px solid #2a2a3c; }
|
||||
.summary-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
|
||||
.summary-section { flex: 1; min-width: 200px; }
|
||||
.summary-label { font-size: 12px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
||||
.summary-row { display: flex; gap: 8px; font-size: 13px; padding: 4px 0; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-row span:first-child { flex: 1; color: #e4e4ef; }
|
||||
.summary-count { color: #8888a0; }
|
||||
.journal-table-wrap { overflow-x: auto; }
|
||||
.journal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.journal-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #2a2a3c; color: #8888a0; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
|
||||
.journal-table td { padding: 8px 12px; border-bottom: 1px solid #1a1a28; color: #e4e4ef; }
|
||||
.journal-table .link-btn { color: #a5b4fc; }
|
||||
.journal-path-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8888a0; font-size: 12px; }
|
||||
.journal-min-cell { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.journal-date-cell { color: #8888a0; white-space: nowrap; }
|
||||
|
||||
/* Today suggestions */
|
||||
.today-suggestions { margin-bottom: 24px; }
|
||||
|
||||
/* Link-style buttons */
|
||||
.link-btn { background: none; border: none; padding: 0; color: #a5b4fc; font: inherit; cursor: pointer; text-align: left; }
|
||||
.link-btn:hover { text-decoration: underline; }
|
||||
|
||||
/* Nav badge */
|
||||
.nav-badge { background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; margin-left: 6px; line-height: 1.4; }
|
||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
|
||||
/* Actions */
|
||||
|
|
|
|||
|
|
@ -123,4 +123,33 @@ export default {
|
|||
|
||||
'worklog.suggestions': 'Suggestions for today',
|
||||
'worklog.apply': 'Apply',
|
||||
|
||||
'nav.journal': 'Journal',
|
||||
|
||||
'journal.title': 'Work Log',
|
||||
'journal.empty': 'No entries for the selected period',
|
||||
'journal.dateFrom': 'From',
|
||||
'journal.dateTo': 'To',
|
||||
'journal.filter': 'Filter',
|
||||
'journal.total': 'Total',
|
||||
'journal.exportCSV': 'CSV',
|
||||
'journal.exportMarkdown': 'Markdown',
|
||||
'journal.billable': 'Billable',
|
||||
'journal.approximate': 'Approx',
|
||||
'journal.node': 'Case',
|
||||
'journal.path': 'Path',
|
||||
'journal.byDay': 'By day',
|
||||
'journal.byNode': 'By case',
|
||||
'journal.includeChildren': 'Include subtasks',
|
||||
|
||||
'suggest.title': 'Suggestions',
|
||||
'suggest.apply': 'Log',
|
||||
'suggest.dismiss': 'Dismiss',
|
||||
'suggest.open': 'Open',
|
||||
'suggest.confidence.low': 'Low confidence',
|
||||
'suggest.confidence.medium': 'Medium confidence',
|
||||
'suggest.confidence.high': 'High confidence',
|
||||
'suggest.minutes': 'min',
|
||||
'suggest.edit': 'Edit',
|
||||
'suggest.noSuggestions': 'No suggestions',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export default {
|
|||
'nav.today': 'Сегодня',
|
||||
'nav.inbox': 'Неразобранное',
|
||||
'nav.activity': 'Активность',
|
||||
'nav.journal': 'Журнал',
|
||||
'nav.clients': 'Клиенты',
|
||||
'nav.projects': 'Проекты',
|
||||
'nav.recipes': 'Рецепты',
|
||||
|
|
@ -198,6 +199,33 @@ export default {
|
|||
'today.plural.event_few': 'события',
|
||||
'today.plural.event_many': 'событий',
|
||||
|
||||
'journal.title': 'Журнал работы',
|
||||
'journal.empty': 'Нет записей за выбранный период',
|
||||
'journal.dateFrom': 'От',
|
||||
'journal.dateTo': 'До',
|
||||
'journal.filter': 'Фильтр',
|
||||
'journal.total': 'Всего',
|
||||
'journal.exportCSV': 'CSV',
|
||||
'journal.exportMarkdown': 'Markdown',
|
||||
'journal.billable': 'Оплачиваемое',
|
||||
'journal.approximate': 'Примерно',
|
||||
'journal.node': 'Дело',
|
||||
'journal.path': 'Путь',
|
||||
'journal.byDay': 'По дням',
|
||||
'journal.byNode': 'По делам',
|
||||
'journal.includeChildren': 'С подзадачами',
|
||||
|
||||
'suggest.title': 'Предложения на сегодня',
|
||||
'suggest.apply': 'Записать',
|
||||
'suggest.dismiss': 'Скрыть',
|
||||
'suggest.open': 'Открыть',
|
||||
'suggest.confidence.low': 'Низкая уверенность',
|
||||
'suggest.confidence.medium': 'Средняя уверенность',
|
||||
'suggest.confidence.high': 'Высокая уверенность',
|
||||
'suggest.minutes': 'мин',
|
||||
'suggest.edit': 'Изменить',
|
||||
'suggest.noSuggestions': 'Нет предложений для журнала',
|
||||
|
||||
'activity.title': 'Активность',
|
||||
'activity.empty': 'Активность пока не зафиксирована',
|
||||
'activity.perCaseEmpty': 'Активность пока не зафиксирована',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
package activity
|
||||
|
||||
// Confidence level for a suggestion.
|
||||
const (
|
||||
ConfidenceLow = "low"
|
||||
ConfidenceMedium = "medium"
|
||||
ConfidenceHigh = "high"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle"`
|
||||
Summary string `json:"summary"`
|
||||
SuggestedMin int `json:"suggestedMin"`
|
||||
EventCount int `json:"eventCount"`
|
||||
NodeKind string `json:"nodeKind"`
|
||||
Confidence string `json:"confidence"`
|
||||
ConfidenceReason string `json:"confidenceReason"`
|
||||
Hidden bool `json:"hidden"`
|
||||
TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
package worklog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// ReportFilter specifies which worklog entries to include.
|
||||
type ReportFilter struct {
|
||||
DateFrom string // "2006-01-02" or "" for no lower bound
|
||||
DateTo string // "2006-01-02" or "" for no upper bound
|
||||
NodeID string // optional filter by node
|
||||
IncludeChildren bool // include descendants of NodeID
|
||||
Billable *bool // nil = all, true/false to filter
|
||||
Approximate *bool // nil = all
|
||||
Section string // filter by node section (requires JOIN)
|
||||
}
|
||||
|
||||
// ReportRow is a single worklog entry with node info.
|
||||
type ReportRow struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle"`
|
||||
NodePath string `json:"nodePath"`
|
||||
Date string `json:"date"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Minutes int `json:"minutes"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SummaryGroup aggregates worklog minutes by a key.
|
||||
type SummaryGroup struct {
|
||||
Label string `json:"label"`
|
||||
Minutes int `json:"minutes"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// ReportSummary contains aggregated totals.
|
||||
type ReportSummary struct {
|
||||
TotalMinutes int `json:"totalMinutes"`
|
||||
TotalEntries int `json:"totalEntries"`
|
||||
ByDay []SummaryGroup `json:"byDay"`
|
||||
ByNode []SummaryGroup `json:"byNode"`
|
||||
}
|
||||
|
||||
// nodeTitleAndParents returns title and full path for a node.
|
||||
// Path is constructed by walking up parent_id chain.
|
||||
func (s *Service) nodeTitleAndParents(nodeID string) (title, path string) {
|
||||
type nodeRow struct {
|
||||
id string
|
||||
parentID *string
|
||||
title string
|
||||
}
|
||||
|
||||
// Walk up the chain.
|
||||
currentID := nodeID
|
||||
var chain []string
|
||||
var nodeTitle string
|
||||
seen := make(map[string]bool)
|
||||
maxDepth := 10
|
||||
|
||||
for i := 0; i < maxDepth; i++ {
|
||||
if currentID == "" || seen[currentID] {
|
||||
break
|
||||
}
|
||||
seen[currentID] = true
|
||||
|
||||
var nr nodeRow
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, parent_id, title FROM nodes WHERE id = ?`, currentID,
|
||||
).Scan(&nr.id, &nr.parentID, &nr.title)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if nodeTitle == "" {
|
||||
nodeTitle = nr.title
|
||||
}
|
||||
chain = append([]string{nr.title}, chain...)
|
||||
if nr.parentID == nil {
|
||||
break
|
||||
}
|
||||
currentID = *nr.parentID
|
||||
}
|
||||
return nodeTitle, strings.Join(chain, " > ")
|
||||
}
|
||||
|
||||
// buildReportQuery constructs the SQL for a filtered worklog report.
|
||||
func (s *Service) buildReportQuery(f ReportFilter) (string, []interface{}) {
|
||||
var conditions []string
|
||||
var args []interface{}
|
||||
|
||||
if f.DateFrom != "" {
|
||||
conditions = append(conditions, "w.date >= ?")
|
||||
args = append(args, f.DateFrom)
|
||||
}
|
||||
if f.DateTo != "" {
|
||||
conditions = append(conditions, "w.date <= ?")
|
||||
args = append(args, f.DateTo)
|
||||
}
|
||||
if f.Billable != nil {
|
||||
conditions = append(conditions, "w.billable = ?")
|
||||
v := 0
|
||||
if *f.Billable {
|
||||
v = 1
|
||||
}
|
||||
args = append(args, v)
|
||||
}
|
||||
if f.Approximate != nil {
|
||||
conditions = append(conditions, "w.approximate = ?")
|
||||
v := 0
|
||||
if *f.Approximate {
|
||||
v = 1
|
||||
}
|
||||
args = append(args, v)
|
||||
}
|
||||
if f.NodeID != "" {
|
||||
if f.IncludeChildren {
|
||||
conditions = append(conditions, `(w.node_id = ? OR w.node_id IN (
|
||||
WITH RECURSIVE descs AS (
|
||||
SELECT id FROM nodes WHERE parent_id = ?
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN descs d ON n.parent_id = d.id
|
||||
) SELECT id FROM descs
|
||||
))`)
|
||||
args = append(args, f.NodeID, f.NodeID)
|
||||
} else {
|
||||
conditions = append(conditions, "w.node_id = ?")
|
||||
args = append(args, f.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(conditions) > 0 {
|
||||
whereClause = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
|
||||
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
|
||||
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
|
||||
w.created_at, w.updated_at
|
||||
FROM worklog_entries w
|
||||
LEFT JOIN nodes n ON n.id = w.node_id` +
|
||||
whereClause +
|
||||
` ORDER BY w.date DESC, w.created_at DESC`
|
||||
|
||||
return q, args
|
||||
}
|
||||
|
||||
// ListReport returns filtered worklog entries with node info.
|
||||
func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
|
||||
q, args := s.buildReportQuery(f)
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []ReportRow
|
||||
for rows.Next() {
|
||||
var r ReportRow
|
||||
var createdStr, updatedStr string
|
||||
var approxInt, billInt int
|
||||
err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
|
||||
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
|
||||
&createdStr, &updatedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Approximate = approxInt == 1
|
||||
r.Billable = billInt == 1
|
||||
r.CreatedAt = createdStr
|
||||
r.UpdatedAt = updatedStr
|
||||
|
||||
// Build path lazily only if needed.
|
||||
if r.NodeTitle == "" {
|
||||
title, _ := s.nodeTitleAndParents(r.NodeID)
|
||||
r.NodeTitle = title
|
||||
}
|
||||
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BuildReportPaths enriches report rows with node paths.
|
||||
func (s *Service) BuildReportPaths(rows []ReportRow) {
|
||||
pathCache := make(map[string]string, len(rows))
|
||||
for i := range rows {
|
||||
if rows[i].NodePath != "" {
|
||||
continue
|
||||
}
|
||||
if p, ok := pathCache[rows[i].NodeID]; ok {
|
||||
rows[i].NodePath = p
|
||||
continue
|
||||
}
|
||||
_, path := s.nodeTitleAndParents(rows[i].NodeID)
|
||||
pathCache[rows[i].NodeID] = path
|
||||
rows[i].NodePath = path
|
||||
}
|
||||
}
|
||||
|
||||
// Summary aggregates worklog entries matching the filter.
|
||||
func (s *Service) Summary(f ReportFilter) (*ReportSummary, error) {
|
||||
rows, err := s.ListReport(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sm := &ReportSummary{}
|
||||
dayMap := make(map[string]int)
|
||||
dayCount := make(map[string]int)
|
||||
nodeMap := make(map[string]int)
|
||||
nodeCount := make(map[string]int)
|
||||
|
||||
for _, r := range rows {
|
||||
sm.TotalMinutes += r.Minutes
|
||||
sm.TotalEntries++
|
||||
dayMap[r.Date] += r.Minutes
|
||||
dayCount[r.Date]++
|
||||
nodeMap[r.NodeTitle] += r.Minutes
|
||||
nodeCount[r.NodeTitle]++
|
||||
}
|
||||
|
||||
for day, min := range dayMap {
|
||||
sm.ByDay = append(sm.ByDay, SummaryGroup{Label: day, Minutes: min, Count: dayCount[day]})
|
||||
}
|
||||
for node, min := range nodeMap {
|
||||
sm.ByNode = append(sm.ByNode, SummaryGroup{Label: node, Minutes: min, Count: nodeCount[node]})
|
||||
}
|
||||
|
||||
return sm, nil
|
||||
}
|
||||
|
||||
// ExportCSV returns a CSV string of the filtered report.
|
||||
func (s *Service) ExportCSV(f ReportFilter) (string, error) {
|
||||
rows, err := s.ListReport(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.BuildReportPaths(rows)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Date,Node,Path,Summary,Minutes,Approximate,Billable,Created\n")
|
||||
for _, r := range rows {
|
||||
approx := "0"
|
||||
if r.Approximate {
|
||||
approx = "1"
|
||||
}
|
||||
bill := "0"
|
||||
if r.Billable {
|
||||
bill = "1"
|
||||
}
|
||||
summary := strings.ReplaceAll(r.Summary, "\"", "\"\"")
|
||||
b.WriteString(fmt.Sprintf("%s,\"%s\",\"%s\",\"%s\",%d,%s,%s,%s\n",
|
||||
r.Date, r.NodeTitle, r.NodePath, summary, r.Minutes, approx, bill, r.CreatedAt))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ExportMarkdown returns a Markdown report.
|
||||
func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
|
||||
rows, err := s.ListReport(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.BuildReportPaths(rows)
|
||||
|
||||
sm, _ := s.Summary(f)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Worklog Report\n\n")
|
||||
|
||||
period := ""
|
||||
if f.DateFrom != "" || f.DateTo != "" {
|
||||
period = fmt.Sprintf(" (%s – %s)", f.DateFrom, f.DateTo)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**Period:**%s\n\n", period))
|
||||
if sm != nil {
|
||||
b.WriteString(fmt.Sprintf("**Total:** %dh %dm (%d entries)\n\n",
|
||||
sm.TotalMinutes/60, sm.TotalMinutes%60, sm.TotalEntries))
|
||||
}
|
||||
|
||||
b.WriteString("| Date | Node | Path | Summary | Minutes |\n")
|
||||
b.WriteString("|------|------|------|---------|--------|\n")
|
||||
for _, r := range rows {
|
||||
approx := ""
|
||||
if r.Approximate {
|
||||
approx = " ~"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %d%s |\n",
|
||||
r.Date, r.NodeTitle, r.NodePath, r.Summary, r.Minutes, approx))
|
||||
}
|
||||
|
||||
if sm != nil {
|
||||
b.WriteString("\n## Summary\n\n")
|
||||
b.WriteString(fmt.Sprintf("**Total:** %dh %dm\n\n", sm.TotalMinutes/60, sm.TotalMinutes%60))
|
||||
|
||||
if len(sm.ByDay) > 0 {
|
||||
b.WriteString("### By Day\n\n")
|
||||
b.WriteString("| Date | Minutes | Entries |\n")
|
||||
b.WriteString("|------|---------|--------|\n")
|
||||
for _, d := range sm.ByDay {
|
||||
b.WriteString(fmt.Sprintf("| %s | %dh %dm | %d |\n", d.Label, d.Minutes/60, d.Minutes%60, d.Count))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// GetByIDWithNode returns a single entry with node title.
|
||||
func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
|
||||
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
|
||||
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
|
||||
w.created_at, w.updated_at
|
||||
FROM worklog_entries w
|
||||
LEFT JOIN nodes n ON n.id = w.node_id
|
||||
WHERE w.id = ?`
|
||||
var r ReportRow
|
||||
var createdStr, updatedStr string
|
||||
var approxInt, billInt int
|
||||
err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
|
||||
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
|
||||
&createdStr, &updatedStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Approximate = approxInt == 1
|
||||
r.Billable = billInt == 1
|
||||
r.CreatedAt = createdStr
|
||||
r.UpdatedAt = updatedStr
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// AddWithDate inserts a worklog entry with a specific date.
|
||||
func (s *Service) AddWithDate(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*Entry, error) {
|
||||
if nodeID == "" {
|
||||
return nil, fmt.Errorf("node_id required")
|
||||
}
|
||||
if summary == "" {
|
||||
return nil, fmt.Errorf("summary required")
|
||||
}
|
||||
if date == "" {
|
||||
date = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: date,
|
||||
Minutes: &minutes,
|
||||
Approximate: approximate,
|
||||
Billable: billable,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
|
||||
summary,details,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
|
||||
boolInt(e.Billable), e.Summary, e.Details,
|
||||
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// UpdateDate updates the date of an entry.
|
||||
func (s *Service) UpdateDate(id, date string) error {
|
||||
t := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE worklog_entries SET date=?, updated_at=? WHERE id=?`,
|
||||
date, t, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("entry not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ = storage.DB{}
|
||||
|
|
@ -50,7 +50,7 @@ func (s *Service) Add(nodeID, summary, details string, minutes int, approximate,
|
|||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: time.Now().UTC().Format("2006-01-02"),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
Minutes: &minutes,
|
||||
Approximate: approximate,
|
||||
Billable: billable,
|
||||
|
|
|
|||
Loading…
Reference in New Issue