feat: worklog source field, suggestion logic fix, modal form, activity navigation

- Add source column to worklog_entries (migration 014): manual/suggestion/unknown
- GetSuggestions now excludes only events linked in worklog_entry_events,
  not entire nodes — repeated activity same day now produces suggestions
- Manual entry form replaced with '+' button + modal dialog
- Source display shows correct origin (manual/suggestion/unknown/no-events)
- Include-children checkbox hidden when no node selected
- Activity events navigate to specific notes/files instead of just case
- Expandable row reactivity fixed (journalRows/worklog reassignment)
This commit is contained in:
mirivlad 2026-06-03 12:27:50 +08:00
parent 1472bb3e6f
commit fd99dd4f5c
18 changed files with 445 additions and 160 deletions

View File

@ -8,6 +8,27 @@
5. **Context menu not closing on action**`handleShowInFolder` calls `closeMenu()`.
6. **Wrong folder when opening file's parent folder**`OpenFolder` checks `n.FsPath == ""` for TypeFile and uses first file record path.
7. **Tab highlight not updating visually** — was using `class={tabClass(tab.id)}` which didn't trigger reactive class updates in Svelte. Switched to `class="tab" class:active={activeTab === tab.id}`.
8. **Journal table expand/collapse** — added explicit ▸/▾ toggle column so it's clear rows are expandable.
9. **Per-node worklog entries** — made entries expandable with ▸/▾, showing details + billable/approximate tags.
10. **Manual worklog entry form** — converted inline form to modal dialog ("+ Добавить запись") with all fields: date, summary, minutes, details, billable, approximate.
11. **"С подзадачами" → "Учитывать вложенные дела"** — renamed, now hidden when no node selected.
12. **Filter/export layout** — split into separate "Фильтры" and "Экспорт отчёта" sections with headings.
13. **Suggestion events** — added "Показать в проводнике" button for file-type events in suggestion detail.
14. **Removed duplicate i18n keys** in `ru.js` (worklog.suggestions, worklog.apply).
15. **Removed unused CSS** (`.journal-filters`, `.wl-meta`, `.worklog-form`).
16. **Added `openNodeFolder(nodeOrId)`** — accepts both string ID and node object.
17. **Added `resetJournalFilters()`** — resets all filters and reloads.
18. **Source field** — added `worklog_entries.source` column (migration 014). Values: manual, suggestion. Old entries default to 'unknown'.
19. **Suggestions now use worklog_entry_events** instead of `HasTodayEntries` — only events already linked to worklog entries are excluded. Repeated activity on the same node today now produces new suggestions.
20. **Activity target navigation** — clicking activity events for notes opens the note tab and loads the specific note. File events open the files tab.
21. **Source display** — detail sections now show accurate source: "Ручная запись", "Из предложения", "Из предложения, но связанные события отсутствуют", or "Источник неизвестен".
## Key patterns
- Always use explicit toggle icons (▸/▾) on expandable rows.
- `CreateWorklogFull` supports all fields: nodeID, summary, details, date, minutes, approximate, billable.
- `openNodeFolder(id)` accepts a string ID or a node object.
- `GetSuggestions` filters out only events already in `worklog_entry_events`, not entire nodes.
- New worklog entries get `source=manual` via `Add`/`AddWithDate`; suggestion entries get `source=suggestion` via `AcceptSuggestionWith`.
# Build instructions

View File

@ -176,6 +176,7 @@ type WorklogDTO struct {
Details string `json:"details,omitempty"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Source string `json:"source"`
CreatedAt string `json:"createdAt"`
}

View File

@ -7,16 +7,31 @@ import (
"time"
"verstak/internal/core/activity"
"verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync"
)
// GetSuggestions analyzes today's activity and returns conservative suggestions.
// Only events not already linked in worklog_entry_events are considered.
func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
events, err := a.activity.ListTodayEvents()
if err != nil || len(events) == 0 {
return nil, err
}
// Determine which event IDs are already accounted for in any worklog entry.
accounted := make(map[string]bool)
rows, err := a.db.Query(`SELECT DISTINCT event_id FROM worklog_entry_events`)
if err == nil {
defer rows.Close()
for rows.Next() {
var eid string
if rows.Scan(&eid) == nil {
accounted[eid] = true
}
}
}
type acc struct {
title string
kind string
@ -24,6 +39,9 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
}
grouped := make(map[string]*acc)
for _, e := range events {
if accounted[e.ID] {
continue
}
grp, ok := grouped[e.NodeID]
if !ok {
n, err := a.nodes.GetActive(e.NodeID)
@ -41,11 +59,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
var suggestions []activity.Suggestion
for nodeID, grp := range grouped {
if grp.title == "" {
continue
}
hasEntries, err := a.worklog.HasTodayEntries(nodeID)
if err != nil || hasEntries {
if grp.title == "" || len(grp.events) == 0 {
continue
}
@ -108,7 +122,7 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
if d == "" {
d = time.Now().Format("2006-01-02")
}
entry, err := a.worklog.AddWithDate(s.NodeID, s.Summary, "", d, minutes, true, false)
entry, err := a.worklog.AddWithSource(s.NodeID, s.Summary, "", d, minutes, true, false, worklog.SourceSuggestion)
if err != nil {
return nil, err
}

View File

@ -207,6 +207,7 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
Details: e.Details,
Approximate: e.Approximate,
Billable: e.Billable,
Source: e.Source,
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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;
}
</style>
<script type="module" crossorigin src="/assets/main-BbnSy6IG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bh5tqssv.css">
<script type="module" crossorigin src="/assets/main-cq32hqy7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
</head>
<body>
<div id="app"></div>

View File

@ -61,6 +61,17 @@
let worklog = []
let worklogMinutes = ''
let worklogSummary = ''
let worklogDate = ''
let worklogDetails = ''
let worklogBillable = false
let worklogApprox = false
let showWorklogModal = false
let wlModalSummary = ''
let wlModalMinutes = ''
let wlModalDate = ''
let wlModalDetails = ''
let wlModalBillable = false
let wlModalApprox = false
let suggestions = []
let suggestionCount = 0
let showCreateNode = false
@ -239,7 +250,7 @@
try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {}
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
try { worklog = initWorklogEntries(await wailsCall('ListWorklog', nodeID)) } catch(e) {}
try {
suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length
@ -795,10 +806,11 @@
})
}
async function openNodeFolder(node) {
async function openNodeFolder(nodeOrId) {
closeContextMenu()
const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id
try {
await wailsCall('OpenFolder', node.id)
await wailsCall('OpenFolder', id)
} catch (e) { error = String(e) }
}
@ -923,24 +935,35 @@
}
// ===== Worklog =====
async function submitWorklog() {
const mins = parseInt(worklogMinutes, 10)
if (!worklogSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
try {
const entry = await wailsCall('CreateWorklog', selectedNode.id, worklogSummary.trim(), mins)
worklog = [...worklog, (entry && entry.id) ? entry : { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
} catch (e) {
worklog = [...worklog, { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }]
function openWorklogModal() {
wlModalSummary = ''
wlModalMinutes = ''
wlModalDate = ''
wlModalDetails = ''
wlModalBillable = false
wlModalApprox = false
showWorklogModal = true
}
worklogSummary = ''
worklogMinutes = ''
function closeWorklogModal() {
showWorklogModal = false
}
async function submitWorklogModal() {
const mins = parseInt(wlModalMinutes, 10)
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return
try {
await wailsCall('CreateWorklogFull', selectedNode.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
} catch (e) { /* fallback */ }
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id)) || worklog
showWorklogModal = false
}
async function refreshAfterSuggestion() {
suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length
if (selectedNode) {
worklog = await wailsCall('ListWorklog', selectedNode.id) || []
worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id))
}
if (selectedSection === 'journal') {
await loadJournal()
@ -969,7 +992,7 @@
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
wailsCall('GetSuggestions'),
])
journalRows = rows || []
journalRows = (rows || []).map(r => { if (r._expanded === undefined) r._expanded = false; return r })
journalSummary = summary || null
suggestions = sugs || []
suggestionCount = suggestions.length
@ -1007,14 +1030,32 @@
async function toggleJournalRow(r) {
r._expanded = !r._expanded
journalRows = journalRows
if (r._expanded && !r._events && r._hasEvents === undefined) {
try {
r._events = await wailsCall('GetWorklogEntryEvents', r.id) || []
r._hasEvents = r._events.length > 0
} catch(e) { r._events = []; r._hasEvents = false }
journalRows = journalRows
} catch(e) { r._events = []; r._hasEvents = false; journalRows = journalRows }
}
}
async function toggleWorklogEntry(e) {
e._expanded = !e._expanded
if (e._expanded && e.source === 'suggestion' && !e._events && e._hasEvents === undefined) {
try {
e._events = await wailsCall('GetWorklogEntryEvents', e.id) || []
e._hasEvents = e._events.length > 0
} catch(e) { e._events = []; e._hasEvents = false }
}
worklog = worklog
}
function initWorklogEntries(entries) {
if (!entries) return []
return entries.map(e => { if (e._expanded === undefined) e._expanded = false; return e })
}
function formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
@ -1059,6 +1100,20 @@
loadJournal()
}
function resetJournalFilters() {
journalDateFrom = ''
journalDateTo = ''
journalNodeID = ''
journalIncludeChildren = false
journalBillableFilter = 'all'
journalApproxFilter = 'all'
journalFilteredNodeTitle = ''
journalSearchQuery = ''
journalSearchResults = []
journalShowResults = false
loadJournal()
}
function downloadFile(name, content, mime) {
const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob)
@ -1245,6 +1300,36 @@
}
}
async function openActivityEvent(ev) {
const targetNode = ev.nodeId
if (!targetNode) return
try {
const node = await wailsCall('GetNodeDetail', targetNode)
if (!node) return
if (ev.targetType === 'note') {
selectNode(node)
activeTab = 'notes'
// If we have a targetId, try to find and open the note
if (ev.targetId) {
// Load notes and open the specific note
try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {}
const note = notes.find(n => n.id === ev.targetId)
if (note) {
// Small delay to let the UI render the notes tab
setTimeout(() => openNote(note), 100)
}
}
} else if (ev.targetType === 'file' || ev.targetType === 'folder') {
selectNode(node)
activeTab = 'files'
} else {
selectNode(node)
}
} catch (e) {
error = String(e)
}
}
// ===== Sync =====
async function loadSyncStatus() {
try {
@ -1659,11 +1744,8 @@
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
<div class="worklog-form">
<input type="text" placeholder={t('worklog.whatDone')} bind:value={worklogSummary} />
<input type="number" placeholder={t('worklog.minutes')} bind:value={worklogMinutes} min="1" />
<button class="btn btn-primary" on:click={submitWorklog}
disabled={!worklogSummary.trim() || !worklogMinutes}>{t('worklog.log')}</button>
<div class="worklog-toolbar">
<button class="btn btn-primary btn-sm" on:click={openWorklogModal}>+ {t('worklog.addEntry')}</button>
</div>
{#if selectedNode && suggestions.filter(s => s.nodeId === selectedNode.id).length > 0}
<div class="worklog-tab-suggestions">
@ -1690,6 +1772,9 @@
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
@ -1702,9 +1787,55 @@
<div class="empty-state"><p>{t('worklog.empty')}</p></div>
{:else}
{#each worklog as e}
<div class="worklog-entry">
<div>{e.summary}</div>
<div class="wl-meta">{e.minutes} {t('worklog.min')} · {formatDate(e.createdAt)}</div>
<div class="worklog-entry" class:expanded={e._expanded} on:click={() => toggleWorklogEntry(e)} role="button" tabindex="0" on:keydown={(e2) => e2.key === 'Enter' && toggleWorklogEntry(e)}>
<div class="worklog-entry-head">
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span>
<span class="worklog-entry-summary">{e.summary}</span>
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</span>
{#if e.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
<span class="worklog-entry-date">{formatDate(e.createdAt)}</span>
</div>
{#if e._expanded}
<div class="worklog-entry-detail">
{#if e.details}
<div class="wl-detail-section">
<span class="wl-detail-label">{t('worklog.details')}</span>
<p>{e.details}</p>
</div>
{/if}
<div class="wl-detail-section">
<span class="wl-detail-label">{t('worklog.source')}</span>
{#if e.source === 'manual'}
<p>{t('worklog.sourceManual')}</p>
{:else if e.source === 'suggestion' && e._events && e._events.length > 0}
<p>{t('worklog.sourceSuggestion')}</p>
{:else if e.source === 'suggestion'}
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
{:else if e.source === 'unknown' || e.source === 'imported'}
<p>{t('worklog.sourceUnknown')}</p>
{:else}
<p>{t('worklog.sourceUnknown')}</p>
{/if}
</div>
{#if e.source === 'suggestion' && e._events && e._events.length > 0}
<div class="wl-detail-section">
<span class="wl-detail-label">{t('journal.relatedEvents')}</span>
{#each e._events as ev}
<div class="journal-event-row">
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
@ -1717,7 +1848,7 @@
{:else}
<div class="activity-events">
{#each caseActivity as ev}
<div class="activity-event">
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
<span class="activity-event-title">{ev.title}</span>
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
@ -1735,7 +1866,9 @@
<div class="journal-screen">
<div class="journal-header">
<h2>{t('journal.title')}</h2>
<div class="journal-filters">
<div class="journal-filter-section">
<div class="journal-filter-heading">{t('journal.filterHeading')}</div>
<div class="journal-filters-row">
<label><span class="label-text">{t('journal.dateFrom')}</span>
<input type="date" bind:value={journalDateFrom} />
</label>
@ -1764,14 +1897,12 @@
{/if}
</div>
</label>
<label class="checkbox-label" title={!journalNodeID ? t('journal.includeChildrenDisabledHint') : ''}>
{#if journalNodeID}
<input type="checkbox" bind:checked={journalIncludeChildren} />
{:else}
<input type="checkbox" disabled title={t('journal.includeChildrenDisabledHint')} />
{/if}
<span class:disabled-hint={!journalNodeID}>{t('journal.includeChildren')}</span>
<label class="checkbox-label">
<input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<span>{t('journal.includeChildren')}</span>
</label>
{/if}
<label title={t('journal.billableHint')}>
<span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}>
@ -1789,6 +1920,12 @@
</select>
</label>
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button>
<button class="btn btn-sm" on:click={resetJournalFilters}>{t('journal.reset')}</button>
</div>
</div>
<div class="journal-export-section">
<div class="journal-export-heading">{t('journal.exportHeading')}</div>
<div class="journal-export-row">
<button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
@ -1797,6 +1934,7 @@
{/if}
</div>
</div>
</div>
{#if suggestions.length > 0}
<div class="journal-suggestions">
@ -1825,6 +1963,9 @@
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
@ -1863,6 +2004,7 @@
<table class="journal-table">
<thead>
<tr>
<th class="journal-toggle-col"></th>
<th>{t('journal.title')}</th>
<th>{t('journal.node')}</th>
<th>{t('journal.path')}</th>
@ -1875,6 +2017,7 @@
<tbody>
{#each journalRows as r}
<tr class="journal-row" class:expanded={r._expanded} on:click={() => toggleJournalRow(r)} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && toggleJournalRow(r)}>
<td class="journal-toggle-col"><span class="journal-toggle-icon">{r._expanded ? '▾' : '▸'}</span></td>
<td class="journal-summary-cell">{r.summary}</td>
<td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
<td class="journal-path-cell">{r.nodePath}</td>
@ -1885,7 +2028,7 @@
</tr>
{#if r._expanded}
<tr class="journal-row-detail">
<td colspan="7">
<td colspan="8">
<div class="journal-detail-body">
{#if r.details}
<div class="journal-detail-section">
@ -1895,7 +2038,19 @@
{/if}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span>
<p>{#if r._hasEvents}{t('worklog.sourceSuggestion')}{:else}{t('worklog.sourceManual')}{/if}</p>
{#if r.source === 'manual'}
<p>{t('worklog.sourceManual')}</p>
{:else if r.source === 'suggestion' && r._events && r._events.length > 0}
<p>{t('worklog.sourceSuggestion')}</p>
{:else if r.source === 'suggestion'}
<p>{t('worklog.sourceSuggestionNoEvents')}</p>
{:else if r.source === 'unknown' || r.source === 'imported'}
<p>{t('worklog.sourceUnknown')}</p>
{:else if r._hasEvents}
<p>{t('worklog.sourceSuggestion')}</p>
{:else}
<p>{t('worklog.sourceUnknown')}</p>
{/if}
</div>
{#if r._events}
<div class="journal-detail-section">
@ -1962,6 +2117,9 @@
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
</div>
{/each}
</div>
@ -2002,7 +2160,7 @@
<div class="today-timeline">
<h3>{t('today.timeline')}</h3>
{#each todayDashboard.events as ev}
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<div class="timeline-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
<span class="timeline-dot"></span>
<span class="timeline-title">{ev.title}</span>
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
@ -2029,7 +2187,7 @@
{:else}
<div class="activity-feed-events">
{#each activityFeed as ev}
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
<div class="activity-feed-body">
<span class="activity-feed-title">{ev.title}</span>
@ -2133,6 +2291,49 @@
</div>
{/if}
{#if showWorklogModal}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}>
<div class="modal modal-worklog">
<h3>{t('worklog.addEntry')}</h3>
<div class="form-group">
<label><span class="label-text">{t('worklog.date')}</span>
<input type="date" bind:value={wlModalDate} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('worklog.whatDone')}</span>
<input type="text" placeholder={t('worklog.whatDone')} bind:value={wlModalSummary} />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('worklog.minutes')}</span>
<input type="number" bind:value={wlModalMinutes} min="1" />
</label>
</div>
<div class="form-group">
<label><span class="label-text">{t('worklog.details')}</span>
<textarea bind:value={wlModalDetails}></textarea>
</label>
</div>
<div class="form-row">
<label class="checkbox-label-inline">
<input type="checkbox" bind:checked={wlModalBillable} />
<span>{t('journal.billableYes')}</span>
</label>
<label class="checkbox-label-inline">
<input type="checkbox" bind:checked={wlModalApprox} />
<span>{t('journal.approxEstimated')}</span>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitWorklogModal}
disabled={!wlModalSummary.trim() || !wlModalMinutes}>{t('common.save')}</button>
<button class="btn" on:click={closeWorklogModal}>{t('common.cancel')}</button>
</div>
</div>
</div>
{/if}
{#if showCreateAction}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateAction} on:keydown={onKeyActivate(cancelCreateAction)}>
<div class="modal">
@ -2410,11 +2611,7 @@
/* Worklog tab */
.worklog-tab { padding: 24px; }
.worklog-form { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; }
.worklog-form input { padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
.worklog-form input:focus { outline: none; border-color: #6366f1; }
.worklog-form input[type="text"] { flex: 1; }
.worklog-form input[type="number"] { width: 70px; }
.worklog-toolbar { margin-bottom: 16px; }
.worklog-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; }
.suggestions-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
.suggestion-summary { font-size: 14px; color: #e4e4ef; }
@ -2446,11 +2643,6 @@
.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; }
@ -2491,7 +2683,7 @@
.journal-selected-node:hover { background: #2a2a4a; }
.journal-node-clear { color: #8888a0; font-size: 14px; margin-left: 4px; }
.journal-status-msg { font-size: 12px; color: #34d399; padding: 4px 8px; background: #1a2a1e; border-radius: 4px; }
.disabled-hint { opacity: 0.5; }
.journal-include-chk { width: 16px; height: 16px; accent-color: #6366f1; cursor: pointer; }
/* Today suggestions */
.today-suggestions { margin-bottom: 24px; }
@ -2502,7 +2694,6 @@
/* Nav badge */
.nav-badge { background: #6366f1; color: #fff; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; margin-left: 6px; line-height: 1.4; }
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
/* Actions */
.actions-tab { padding: 24px; }
@ -2532,7 +2723,10 @@
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 12px; padding: 24px; width: 400px; max-width: 90vw; }
.modal h3 { font-size: 18px; margin-bottom: 16px; }
.modal textarea { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; min-height: 60px; resize: vertical; box-sizing: border-box; }
.modal-worklog { width: 460px; }
.form-group { margin-bottom: 12px; }
.form-row { display: flex; gap: 16px; margin-bottom: 12px; }
.form-group label { display: block; }
.form-group .label-text, .form-group .form-label { display: block; font-size: 12px; color: #666; margin-bottom: 4px; }
.form-group input, .form-group select { width: 100%; padding: 8px 12px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 14px; font-family: inherit; }
@ -2646,4 +2840,39 @@
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
/* Journal filter/export sections */
.journal-filter-section { margin-bottom: 12px; padding: 12px 16px; background: #1a1a2e; border: 1px solid #2a2a3c; border-radius: 8px; }
.journal-filter-heading { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 8px; }
.journal-filters-row { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: flex-end; }
.journal-filters-row label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #8888a0; }
.journal-filters-row input[type="date"] { padding: 5px 8px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 12px; font-family: inherit; }
.journal-filters-row .checkbox-label { flex-direction: row; align-items: center; gap: 6px; cursor: pointer; }
.journal-filters-row .checkbox-label input { width: auto; }
.journal-export-section { padding: 8px 16px; background: #1a1a2e; border: 1px solid #2a2a3c; border-radius: 8px; }
.journal-export-heading { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 6px; }
.journal-export-row { display: flex; gap: 8px; align-items: center; }
/* Journal toggle column */
.journal-toggle-col { width: 32px; min-width: 32px; text-align: center; padding: 8px 4px !important; }
.journal-toggle-icon { color: #6366f1; font-size: 12px; }
/* Per-node worklog expandable entries */
.worklog-entry { padding: 8px 12px; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 6px; cursor: pointer; }
.worklog-entry:hover { border-color: #3a3a5c; }
.worklog-entry.expanded { border-color: #6366f1; }
.worklog-entry-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #e4e4ef; }
.worklog-toggle { color: #6366f1; font-size: 12px; width: 16px; text-align: center; flex-shrink: 0; }
.worklog-entry-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worklog-entry-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; white-space: nowrap; }
.worklog-entry-date { color: #b0b0c0; font-size: 12px; white-space: nowrap; }
.worklog-entry-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }
.wl-detail-section { font-size: 13px; }
.wl-detail-label { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 2px; }
.wl-detail-section p { margin: 0; color: #c0c0d0; }
.wl-tag-billable { font-size: 10px; color: #34d399; background: #1a2a1e; padding: 1px 6px; border-radius: 8px; white-space: nowrap; }
.wl-tag-approx { font-size: 10px; color: #fbbf24; background: #2a2a1a; padding: 1px 6px; border-radius: 8px; white-space: nowrap; }
.checkbox-label-inline { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: #e4e4ef; cursor: pointer; }
.checkbox-label-inline input { width: auto; }
</style>

View File

@ -128,11 +128,15 @@ export default {
'worklog.minutes': 'Min',
'worklog.min': 'min',
'worklog.log': 'Log',
'worklog.addEntry': 'Add entry',
'worklog.date': 'Date',
'worklog.empty': 'No work logged yet',
'worklog.details': 'Details',
'worklog.source': 'Source',
'worklog.sourceSuggestion': 'Activity suggestion',
'worklog.sourceSuggestion': 'From suggestion',
'worklog.sourceSuggestionNoEvents': 'From suggestion, but related events missing',
'worklog.sourceManual': 'Manual entry',
'worklog.sourceUnknown': 'Unknown source',
'common.all': 'All',
'common.no': 'No',
@ -166,6 +170,9 @@ export default {
'journal.includeChildren': 'Include subtasks',
'journal.nodeSearch': 'Search case...',
'journal.relatedEvents': 'Related events',
'journal.filterHeading': 'Filters',
'journal.exportHeading': 'Export Report',
'journal.reset': 'Reset',
'suggest.title': 'Suggestions',
'suggest.apply': 'Log',

View File

@ -156,13 +156,15 @@ export default {
'worklog.minutes': 'Мин',
'worklog.min': 'мин',
'worklog.log': 'Записать',
'worklog.addEntry': 'Добавить запись',
'worklog.date': 'Дата',
'worklog.empty': 'Записей работы пока нет',
'worklog.details': 'Детали',
'worklog.source': 'Источник',
'worklog.sourceSuggestion': 'Предложение activity',
'worklog.sourceSuggestion': 'Из предложения',
'worklog.sourceSuggestionNoEvents': 'Из предложения, но связанные события отсутствуют',
'worklog.sourceManual': 'Ручная запись',
'worklog.suggestions': 'Предложения',
'worklog.apply': 'Применить',
'worklog.sourceUnknown': 'Источник неизвестен',
'worklog.suggestions': 'Предложения на сегодня',
'worklog.apply': 'Применить',
@ -231,9 +233,12 @@ export default {
'journal.path': 'Путь',
'journal.byDay': 'По дням',
'journal.byNode': 'По делам',
'journal.includeChildren': 'С подзадачами',
'journal.includeChildren': 'Учитывать вложенные дела',
'journal.nodeSearch': 'Поиск дела...',
'journal.relatedEvents': 'Связанные события',
'journal.filterHeading': 'Фильтры',
'journal.exportHeading': 'Экспорт отчёта',
'journal.reset': 'Сбросить',
'suggest.title': 'Предложения на сегодня',
'suggest.apply': 'Записать',

View File

@ -0,0 +1,6 @@
package storage
// migration014 — add source column to worklog_entries.
const migration014 = `
ALTER TABLE worklog_entries ADD COLUMN source TEXT NOT NULL DEFAULT 'unknown';
`

View File

@ -70,6 +70,7 @@ var migrationFiles = map[int]string{
11: migration011,
12: migration012,
13: migration013,
14: migration014,
}
func (db *DB) runInitialSchema() error {

View File

@ -8,7 +8,6 @@ import (
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// ReportFilter specifies which worklog entries to include.
@ -33,6 +32,7 @@ type ReportRow struct {
Minutes int `json:"minutes"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Source string `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
@ -145,7 +145,7 @@ func (s *Service) buildReportQuery(f ReportFilter) (string, []interface{}) {
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
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id` +
whereClause +
@ -170,7 +170,7 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
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)
&r.Source, &createdStr, &updatedStr)
if err != nil {
return nil, err
}
@ -349,7 +349,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
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
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id
WHERE w.id = ?`
@ -358,7 +358,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
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)
&r.Source, &createdStr, &updatedStr)
if err != nil {
return nil, err
}
@ -371,41 +371,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
// 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
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
}
// UpdateDate updates the date of an entry.

View File

@ -22,10 +22,18 @@ type Entry struct {
Billable bool `json:"billable"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
SourceManual = "manual"
SourceSuggestion = "suggestion"
SourceImported = "imported"
SourceUnknown = "unknown"
)
// Service manages worklog entries.
type Service struct {
db *storage.DB
@ -36,34 +44,44 @@ func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Add inserts a new worklog entry.
// Add inserts a new worklog entry with manual source.
func (s *Service) Add(nodeID, summary, details string, minutes int, approximate, billable bool) (*Entry, error) {
date := time.Now().Format("2006-01-02")
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
}
// Add inserts a new worklog entry.
func (s *Service) AddWithSource(nodeID, summary, details, date string, minutes int, approximate, billable bool, source string) (*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: time.Now().Format("2006-01-02"),
Date: date,
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
Source: source,
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 (?,?,?,?,?,?,?,?,?,?)`,
summary,details,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
boolInt(e.Billable), e.Summary, e.Details, e.Source,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
@ -94,7 +112,7 @@ func (s *Service) Update(id, summary, details string, minutes int, approximate,
func (s *Service) Get(id string) (*Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
FROM worklog_entries WHERE id=?`, id)
if err != nil {
return nil, err
@ -110,7 +128,7 @@ func (s *Service) Get(id string) (*Entry, error) {
func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
if err != nil {
return nil, err
@ -210,14 +228,14 @@ type rowScanner interface {
func scanEntry(s rowScanner) (*Entry, error) {
var e Entry
var startedAt, endedAt, details sql.NullString
var startedAt, endedAt, details, source sql.NullString
var minutes sql.NullInt64
var createdStr, updatedStr string
var approxInt, billInt int
err := s.Scan(
&e.ID, &e.NodeID, &startedAt, &endedAt, &e.Date, &minutes,
&approxInt, &billInt, &e.Summary, &details, &createdStr, &updatedStr,
&approxInt, &billInt, &e.Summary, &details, &source, &createdStr, &updatedStr,
)
if err != nil {
return nil, err
@ -239,6 +257,10 @@ func scanEntry(s rowScanner) (*Entry, error) {
if details.Valid {
e.Details = details.String
}
e.Source = SourceUnknown
if source.Valid {
e.Source = source.String
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
return &e, nil