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:
mirivlad 2026-06-03 09:56:17 +08:00
parent 57d13c9506
commit c25e75f839
14 changed files with 1030 additions and 120 deletions

View File

@ -169,8 +169,13 @@ 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"`
NodeTitle string `json:"nodeTitle,omitempty"`
Summary string `json:"summary"` Summary string `json:"summary"`
Minutes int `json:"minutes"` 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"` CreatedAt string `json:"createdAt"`
} }

View File

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

View File

@ -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
}
}

View File

@ -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 { 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
}
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 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

View File

@ -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>

View File

@ -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 (530 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
- 530 мин, 60+ только при сильном evidence
- Confidence: low/medium/high + reason
--- ---

View File

@ -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,18 +1606,20 @@
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>
<button class="btn btn-sm btn-primary" on:click={() => acceptSuggestion(s)}> <div class="suggestion-actions">
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>
{t('worklog.apply')} {t('worklog.apply')}
</button> </button>
</div> </div>
</div>
{/each} {/each}
</div> </div>
{/if} {/if}
@ -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 */

View File

@ -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',
} }

View File

@ -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': 'Активность пока не зафиксирована',

View File

@ -1,5 +1,12 @@
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"`
@ -8,4 +15,8 @@ type Suggestion struct {
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
} }

View File

@ -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{}

View File

@ -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,