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 {
|
type WorklogDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
Summary string `json:"summary"`
|
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||||
Minutes int `json:"minutes"`
|
Summary string `json:"summary"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type SearchResultDTO struct {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ func (a *App) ListSystemViews() []SystemViewDTO {
|
||||||
return []SystemViewDTO{
|
return []SystemViewDTO{
|
||||||
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
{ID: "today", Label: i18n.TF("ru", "nav.today")},
|
||||||
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
{ID: "inbox", Label: i18n.TF("ru", "nav.inbox")},
|
||||||
|
{ID: "journal", Label: i18n.TF("ru", "nav.journal")},
|
||||||
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
{ID: "activity", Label: i18n.TF("ru", "nav.activity")},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,18 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"verstak/internal/core/activity"
|
"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) {
|
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
events, err := a.activity.ListTodayEvents()
|
events, err := a.activity.ListTodayEvents()
|
||||||
if err != nil {
|
if err != nil || len(events) == 0 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(events) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type acc struct {
|
type acc struct {
|
||||||
title string
|
title string
|
||||||
|
|
@ -50,37 +49,27 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
noteCount := 0
|
notes, files, actions, other := countByType(grp.events)
|
||||||
fileCount := 0
|
summary := buildSuggestionSummary(notes, files, actions, other)
|
||||||
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 == "" {
|
if summary == "" {
|
||||||
continue
|
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{
|
suggestions = append(suggestions, activity.Suggestion{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
NodeTitle: grp.title,
|
NodeTitle: grp.title,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
SuggestedMin: suggestMinutes(noteCount + fileCount + actionCount + otherCount),
|
SuggestedMin: min,
|
||||||
EventCount: len(grp.events),
|
EventCount: len(grp.events),
|
||||||
NodeKind: grp.kind,
|
NodeKind: grp.kind,
|
||||||
|
Confidence: conf,
|
||||||
|
ConfidenceReason: reason,
|
||||||
|
TimeSpreadMin: spread,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,39 +80,160 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
return suggestions, nil
|
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) {
|
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
|
var parts []string
|
||||||
if noteCount > 0 {
|
if notes > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("заметки (%d)", noteCount))
|
parts = append(parts, fmt.Sprintf("заметки (%d)", notes))
|
||||||
}
|
}
|
||||||
if fileCount > 0 {
|
if files > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("файлы (%d)", fileCount))
|
parts = append(parts, fmt.Sprintf("файлы (%d)", files))
|
||||||
}
|
}
|
||||||
if actionCount > 0 {
|
if actions > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("действия (%d)", actionCount))
|
parts = append(parts, fmt.Sprintf("действия (%d)", actions))
|
||||||
}
|
}
|
||||||
if otherCount > 0 {
|
if other > 0 {
|
||||||
parts = append(parts, fmt.Sprintf("события (%d)", otherCount))
|
parts = append(parts, fmt.Sprintf("события (%d)", other))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, ", ")
|
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 (
|
import (
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||||
|
|
@ -9,38 +10,101 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := make([]WorklogDTO, len(list))
|
return toWorklogDTOs(list), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
|
_ = 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
|
mins := 0
|
||||||
if entry.Minutes != nil {
|
if e.Minutes != nil {
|
||||||
mins = *entry.Minutes
|
mins = *e.Minutes
|
||||||
}
|
}
|
||||||
return &WorklogDTO{
|
return &WorklogDTO{
|
||||||
ID: entry.ID,
|
ID: e.ID,
|
||||||
NodeID: entry.NodeID,
|
NodeID: e.NodeID,
|
||||||
Summary: entry.Summary,
|
Summary: e.Summary,
|
||||||
Minutes: mins,
|
Minutes: mins,
|
||||||
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
Date: e.Date,
|
||||||
}, nil
|
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;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-C6ZVnS_E.js"></script>
|
<script type="module" crossorigin src="/assets/main-wlKdkTmp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-8mzuSvQb.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-B4G76NhT.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<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) |
|
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
|
||||||
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
|
||||||
| 14 | **MVP stabilization** | ✅ выполнено |
|
| 14 | **MVP stabilization** | ✅ выполнено |
|
||||||
| 15 | **Sync Server + Client** | 🔄 в работе — HTTP API, push/pull, blob sync |
|
| 15 | **Sync Server + Client** | ✅ выполнено |
|
||||||
| 16 | **Activity Suggestions** | ⏳ ожидает — подсказки из activity_events |
|
| 16 | **Activity Suggestions** | 🔄 в работе — global worklog dashboard + conservative suggestions + UX |
|
||||||
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
| 17 | **File Scanner/Watcher** | ⏳ ожидает — fsnotify, snapshot scanner |
|
||||||
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
| 18 | **TUI MVP (Bubble Tea)** | ⏳ ожидает — tree/search, add worklog |
|
||||||
| 19 | **Plugins: Lua runtime** | ⏳ ожидает — gopher-lua, hooks, sandbox |
|
| 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)
|
- `suggest.go` — структура Suggestion (nodeId, nodeTitle, summary, suggestedMin, confidence)
|
||||||
- `internal/core/sync/` — клиент синхронизации
|
- `bindings_suggest.go` — GetSuggestions с burst detection + conservative estimator (5–30 min)
|
||||||
- SQLite-based sync state (`migrations/008_sync.sql`)
|
- `bindings_suggest.go` — AcceptSuggestionWith (date/minutes overrides)
|
||||||
- FS-first apply: изменения применяются через файловую систему, БД — индекс
|
- `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 activityFeed = []
|
||||||
let activityOffset = 0
|
let activityOffset = 0
|
||||||
let activityHasMore = true
|
let activityHasMore = true
|
||||||
|
let journalRows = []
|
||||||
|
let journalSummary = null
|
||||||
|
let journalDateFrom = ''
|
||||||
|
let journalDateTo = ''
|
||||||
|
let journalIncludeChildren = false
|
||||||
let activityLoading = false
|
let activityLoading = false
|
||||||
let caseActivity = []
|
let caseActivity = []
|
||||||
let version = ''
|
let version = ''
|
||||||
|
|
@ -49,6 +54,7 @@
|
||||||
let worklogMinutes = ''
|
let worklogMinutes = ''
|
||||||
let worklogSummary = ''
|
let worklogSummary = ''
|
||||||
let suggestions = []
|
let suggestions = []
|
||||||
|
let suggestionCount = 0
|
||||||
let showCreateNode = false
|
let showCreateNode = false
|
||||||
let newNodeTitle = ''
|
let newNodeTitle = ''
|
||||||
let createInNode = null
|
let createInNode = null
|
||||||
|
|
@ -177,9 +183,15 @@
|
||||||
activityFeed = []
|
activityFeed = []
|
||||||
activityOffset = 0
|
activityOffset = 0
|
||||||
activityHasMore = true
|
activityHasMore = true
|
||||||
|
journalRows = []
|
||||||
|
journalSummary = null
|
||||||
try {
|
try {
|
||||||
if (id === 'today') {
|
if (id === 'today') {
|
||||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||||
|
suggestions = await wailsCall('GetSuggestions') || []
|
||||||
|
suggestionCount = suggestions.length
|
||||||
|
} else if (id === 'journal') {
|
||||||
|
await loadJournal()
|
||||||
} else if (id === 'activity') {
|
} else if (id === 'activity') {
|
||||||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||||
activityOffset = activityFeed.length
|
activityOffset = activityFeed.length
|
||||||
|
|
@ -220,7 +232,10 @@
|
||||||
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 {
|
||||||
|
suggestions = await wailsCall('GetSuggestions') || []
|
||||||
|
suggestionCount = suggestions.length
|
||||||
|
} catch(e) { suggestions = []; suggestionCount = 0 }
|
||||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -913,18 +928,84 @@
|
||||||
worklogMinutes = ''
|
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) {
|
async function acceptSuggestion(s) {
|
||||||
try {
|
try {
|
||||||
await wailsCall('AcceptSuggestion', s)
|
await wailsCall('AcceptSuggestionWith', s, s.suggestedMin, '')
|
||||||
|
suggestions = await wailsCall('GetSuggestions') || []
|
||||||
|
suggestionCount = suggestions.length
|
||||||
if (selectedNode) {
|
if (selectedNode) {
|
||||||
try { worklog = await wailsCall('ListWorklog', selectedNode.id) || [] } catch(e) {}
|
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
|
||||||
try { suggestions = await wailsCall('GetSuggestions') || [] } catch(e) { suggestions = [] }
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('accept suggestion', 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 =====
|
// ===== Files =====
|
||||||
async function addFile() {
|
async function addFile() {
|
||||||
const path = await wailsCall('PickFile')
|
const path = await wailsCall('PickFile')
|
||||||
|
|
@ -1220,6 +1301,9 @@
|
||||||
<button class="nav-item {selectedSection === view.id ? 'selected' : ''}"
|
<button class="nav-item {selectedSection === view.id ? 'selected' : ''}"
|
||||||
on:click={() => selectSystemView(view.id)}>
|
on:click={() => selectSystemView(view.id)}>
|
||||||
{view.label}
|
{view.label}
|
||||||
|
{#if view.id === 'journal' && suggestionCount > 0}
|
||||||
|
<span class="nav-badge">{suggestionCount}</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1522,17 +1606,19 @@
|
||||||
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}
|
{#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>
|
<div class="suggestions-title">{t('worklog.suggestions')}</div>
|
||||||
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
|
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
|
||||||
<div class="suggestion">
|
<div class="suggestion-card">
|
||||||
<div class="suggestion-body">
|
<div class="suggestion-info">
|
||||||
<div class="suggestion-summary">{s.summary}</div>
|
<span class="suggestion-summary">{s.summary}</span>
|
||||||
<div class="suggestion-meta">{s.suggestedMin} {t('worklog.min')}</div>
|
<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>
|
</div>
|
||||||
<button class="btn btn-sm btn-primary" on:click={() => acceptSuggestion(s)}>
|
|
||||||
{t('worklog.apply')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1570,6 +1656,104 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{:else if selectedSection === 'today' && todayDashboard}
|
||||||
<div class="today-dashboard">
|
<div class="today-dashboard">
|
||||||
<div class="today-header">
|
<div class="today-header">
|
||||||
|
|
@ -1584,6 +1768,27 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||||||
{#each todayDashboard.groups as group}
|
{#each todayDashboard.groups as group}
|
||||||
<div class="today-case">
|
<div class="today-case">
|
||||||
|
|
@ -2029,13 +2234,58 @@
|
||||||
.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; }
|
.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-summary { font-size: 14px; color: #e4e4ef; }
|
||||||
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
.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; }
|
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
|
|
|
||||||
|
|
@ -123,4 +123,33 @@ export default {
|
||||||
|
|
||||||
'worklog.suggestions': 'Suggestions for today',
|
'worklog.suggestions': 'Suggestions for today',
|
||||||
'worklog.apply': 'Apply',
|
'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.today': 'Сегодня',
|
||||||
'nav.inbox': 'Неразобранное',
|
'nav.inbox': 'Неразобранное',
|
||||||
'nav.activity': 'Активность',
|
'nav.activity': 'Активность',
|
||||||
|
'nav.journal': 'Журнал',
|
||||||
'nav.clients': 'Клиенты',
|
'nav.clients': 'Клиенты',
|
||||||
'nav.projects': 'Проекты',
|
'nav.projects': 'Проекты',
|
||||||
'nav.recipes': 'Рецепты',
|
'nav.recipes': 'Рецепты',
|
||||||
|
|
@ -198,6 +199,33 @@ export default {
|
||||||
'today.plural.event_few': 'события',
|
'today.plural.event_few': 'события',
|
||||||
'today.plural.event_many': 'событий',
|
'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.title': 'Активность',
|
||||||
'activity.empty': 'Активность пока не зафиксирована',
|
'activity.empty': 'Активность пока не зафиксирована',
|
||||||
'activity.perCaseEmpty': 'Активность пока не зафиксирована',
|
'activity.perCaseEmpty': 'Активность пока не зафиксирована',
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
package activity
|
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.
|
// Suggestion represents a suggested worklog entry derived from today's activity.
|
||||||
type Suggestion struct {
|
type Suggestion struct {
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
NodeTitle string `json:"nodeTitle"`
|
NodeTitle string `json:"nodeTitle"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
SuggestedMin int `json:"suggestedMin"`
|
SuggestedMin int `json:"suggestedMin"`
|
||||||
EventCount int `json:"eventCount"`
|
EventCount int `json:"eventCount"`
|
||||||
NodeKind string `json:"nodeKind"`
|
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,
|
NodeID: nodeID,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Details: details,
|
Details: details,
|
||||||
Date: time.Now().UTC().Format("2006-01-02"),
|
Date: time.Now().Format("2006-01-02"),
|
||||||
Minutes: &minutes,
|
Minutes: &minutes,
|
||||||
Approximate: approximate,
|
Approximate: approximate,
|
||||||
Billable: billable,
|
Billable: billable,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue