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()`. 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. 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}`. 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 # Build instructions

View File

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

View File

@ -7,16 +7,31 @@ import (
"time" "time"
"verstak/internal/core/activity" "verstak/internal/core/activity"
"verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync" syncsvc "verstak/internal/core/sync"
) )
// GetSuggestions analyzes today's activity and returns conservative suggestions. // 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) { func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
events, err := a.activity.ListTodayEvents() events, err := a.activity.ListTodayEvents()
if err != nil || len(events) == 0 { if err != nil || len(events) == 0 {
return nil, err 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 { type acc struct {
title string title string
kind string kind string
@ -24,6 +39,9 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
} }
grouped := make(map[string]*acc) grouped := make(map[string]*acc)
for _, e := range events { for _, e := range events {
if accounted[e.ID] {
continue
}
grp, ok := grouped[e.NodeID] grp, ok := grouped[e.NodeID]
if !ok { if !ok {
n, err := a.nodes.GetActive(e.NodeID) n, err := a.nodes.GetActive(e.NodeID)
@ -41,11 +59,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
var suggestions []activity.Suggestion var suggestions []activity.Suggestion
for nodeID, grp := range grouped { for nodeID, grp := range grouped {
if grp.title == "" { if grp.title == "" || len(grp.events) == 0 {
continue
}
hasEntries, err := a.worklog.HasTodayEntries(nodeID)
if err != nil || hasEntries {
continue continue
} }
@ -108,7 +122,7 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
if d == "" { if d == "" {
d = time.Now().Format("2006-01-02") 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -207,6 +207,7 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
Details: e.Details, Details: e.Details,
Approximate: e.Approximate, Approximate: e.Approximate,
Billable: e.Billable, Billable: e.Billable,
Source: e.Source,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"), 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; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BbnSy6IG.js"></script> <script type="module" crossorigin src="/assets/main-cq32hqy7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bh5tqssv.css"> <link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -61,6 +61,17 @@
let worklog = [] let worklog = []
let worklogMinutes = '' let worklogMinutes = ''
let worklogSummary = '' 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 suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let showCreateNode = false let showCreateNode = false
@ -239,7 +250,7 @@
try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {} try { notes = await wailsCall('ListNotes', nodeID) || [] } catch(e) {}
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 = initWorklogEntries(await wailsCall('ListWorklog', nodeID)) } catch(e) {}
try { try {
suggestions = await wailsCall('GetSuggestions') || [] suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
@ -795,10 +806,11 @@
}) })
} }
async function openNodeFolder(node) { async function openNodeFolder(nodeOrId) {
closeContextMenu() closeContextMenu()
const id = typeof nodeOrId === 'string' ? nodeOrId : nodeOrId.id
try { try {
await wailsCall('OpenFolder', node.id) await wailsCall('OpenFolder', id)
} catch (e) { error = String(e) } } catch (e) { error = String(e) }
} }
@ -923,24 +935,35 @@
} }
// ===== Worklog ===== // ===== Worklog =====
async function submitWorklog() { function openWorklogModal() {
const mins = parseInt(worklogMinutes, 10) wlModalSummary = ''
if (!worklogSummary.trim() || isNaN(mins) || mins <= 0 || !selectedNode) return wlModalMinutes = ''
try { wlModalDate = ''
const entry = await wailsCall('CreateWorklog', selectedNode.id, worklogSummary.trim(), mins) wlModalDetails = ''
worklog = [...worklog, (entry && entry.id) ? entry : { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }] wlModalBillable = false
} catch (e) { wlModalApprox = false
worklog = [...worklog, { id: Date.now().toString(), nodeId: selectedNode.id, summary: worklogSummary.trim(), minutes: mins, createdAt: new Date().toISOString() }] 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() { async function refreshAfterSuggestion() {
suggestions = await wailsCall('GetSuggestions') || [] suggestions = await wailsCall('GetSuggestions') || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
if (selectedNode) { if (selectedNode) {
worklog = await wailsCall('ListWorklog', selectedNode.id) || [] worklog = initWorklogEntries(await wailsCall('ListWorklog', selectedNode.id))
} }
if (selectedSection === 'journal') { if (selectedSection === 'journal') {
await loadJournal() await loadJournal()
@ -969,7 +992,7 @@
wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter), wailsCall('WorklogReportSummary', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter),
wailsCall('GetSuggestions'), wailsCall('GetSuggestions'),
]) ])
journalRows = rows || [] journalRows = (rows || []).map(r => { if (r._expanded === undefined) r._expanded = false; return r })
journalSummary = summary || null journalSummary = summary || null
suggestions = sugs || [] suggestions = sugs || []
suggestionCount = suggestions.length suggestionCount = suggestions.length
@ -1007,14 +1030,32 @@
async function toggleJournalRow(r) { async function toggleJournalRow(r) {
r._expanded = !r._expanded r._expanded = !r._expanded
journalRows = journalRows
if (r._expanded && !r._events && r._hasEvents === undefined) { if (r._expanded && !r._events && r._hasEvents === undefined) {
try { try {
r._events = await wailsCall('GetWorklogEntryEvents', r.id) || [] r._events = await wailsCall('GetWorklogEntryEvents', r.id) || []
r._hasEvents = r._events.length > 0 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) { function formatTime(iso) {
if (!iso) return '' if (!iso) return ''
const d = new Date(iso) const d = new Date(iso)
@ -1059,6 +1100,20 @@
loadJournal() loadJournal()
} }
function resetJournalFilters() {
journalDateFrom = ''
journalDateTo = ''
journalNodeID = ''
journalIncludeChildren = false
journalBillableFilter = 'all'
journalApproxFilter = 'all'
journalFilteredNodeTitle = ''
journalSearchQuery = ''
journalSearchResults = []
journalShowResults = false
loadJournal()
}
function downloadFile(name, content, mime) { function downloadFile(name, content, mime) {
const blob = new Blob([content], { type: mime }) const blob = new Blob([content], { type: mime })
const url = URL.createObjectURL(blob) 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 ===== // ===== Sync =====
async function loadSyncStatus() { async function loadSyncStatus() {
try { try {
@ -1659,11 +1744,8 @@
{:else if activeTab === 'worklog'} {:else if activeTab === 'worklog'}
<div class="worklog-tab"> <div class="worklog-tab">
<div class="worklog-form"> <div class="worklog-toolbar">
<input type="text" placeholder={t('worklog.whatDone')} bind:value={worklogSummary} /> <button class="btn btn-primary btn-sm" on:click={openWorklogModal}>+ {t('worklog.addEntry')}</button>
<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> </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="worklog-tab-suggestions"> <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-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span> <span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button> <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> </div>
{/each} {/each}
</div> </div>
@ -1702,9 +1787,55 @@
<div class="empty-state"><p>{t('worklog.empty')}</p></div> <div class="empty-state"><p>{t('worklog.empty')}</p></div>
{:else} {:else}
{#each worklog as e} {#each worklog as e}
<div class="worklog-entry"> <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>{e.summary}</div> <div class="worklog-entry-head">
<div class="wl-meta">{e.minutes} {t('worklog.min')} · {formatDate(e.createdAt)}</div> <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> </div>
{/each} {/each}
{/if} {/if}
@ -1717,7 +1848,7 @@
{:else} {:else}
<div class="activity-events"> <div class="activity-events">
{#each caseActivity as ev} {#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-icon">{eventIcon(ev.eventType)}</span>
<span class="activity-event-title">{ev.title}</span> <span class="activity-event-title">{ev.title}</span>
<span class="activity-event-type">{eventLabel(ev.eventType)}</span> <span class="activity-event-type">{eventLabel(ev.eventType)}</span>
@ -1735,7 +1866,9 @@
<div class="journal-screen"> <div class="journal-screen">
<div class="journal-header"> <div class="journal-header">
<h2>{t('journal.title')}</h2> <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> <label><span class="label-text">{t('journal.dateFrom')}</span>
<input type="date" bind:value={journalDateFrom} /> <input type="date" bind:value={journalDateFrom} />
</label> </label>
@ -1764,14 +1897,12 @@
{/if} {/if}
</div> </div>
</label> </label>
<label class="checkbox-label" title={!journalNodeID ? t('journal.includeChildrenDisabledHint') : ''}>
{#if journalNodeID} {#if journalNodeID}
<input type="checkbox" bind:checked={journalIncludeChildren} /> <label class="checkbox-label">
{:else} <input type="checkbox" bind:checked={journalIncludeChildren} class="journal-include-chk" />
<input type="checkbox" disabled title={t('journal.includeChildrenDisabledHint')} /> <span>{t('journal.includeChildren')}</span>
{/if}
<span class:disabled-hint={!journalNodeID}>{t('journal.includeChildren')}</span>
</label> </label>
{/if}
<label title={t('journal.billableHint')}> <label title={t('journal.billableHint')}>
<span class="label-text">{t('journal.billable')}</span> <span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}> <select bind:value={journalBillableFilter}>
@ -1789,6 +1920,12 @@
</select> </select>
</label> </label>
<button class="btn btn-sm" on:click={loadJournal}>{t('journal.filter')}</button> <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('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('markdown')}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button> <button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
@ -1797,6 +1934,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</div>
{#if suggestions.length > 0} {#if suggestions.length > 0}
<div class="journal-suggestions"> <div class="journal-suggestions">
@ -1825,6 +1963,9 @@
<span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span> <span class="suggestion-event-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span> <span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button> <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> </div>
{/each} {/each}
</div> </div>
@ -1863,6 +2004,7 @@
<table class="journal-table"> <table class="journal-table">
<thead> <thead>
<tr> <tr>
<th class="journal-toggle-col"></th>
<th>{t('journal.title')}</th> <th>{t('journal.title')}</th>
<th>{t('journal.node')}</th> <th>{t('journal.node')}</th>
<th>{t('journal.path')}</th> <th>{t('journal.path')}</th>
@ -1875,6 +2017,7 @@
<tbody> <tbody>
{#each journalRows as r} {#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)}> <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 class="journal-summary-cell">{r.summary}</td>
<td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td> <td><button class="link-btn" on:click|stopPropagation={() => openNodeById(r.nodeId)}>{r.nodeTitle}</button></td>
<td class="journal-path-cell">{r.nodePath}</td> <td class="journal-path-cell">{r.nodePath}</td>
@ -1885,7 +2028,7 @@
</tr> </tr>
{#if r._expanded} {#if r._expanded}
<tr class="journal-row-detail"> <tr class="journal-row-detail">
<td colspan="7"> <td colspan="8">
<div class="journal-detail-body"> <div class="journal-detail-body">
{#if r.details} {#if r.details}
<div class="journal-detail-section"> <div class="journal-detail-section">
@ -1895,7 +2038,19 @@
{/if} {/if}
<div class="journal-detail-section"> <div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.source')}</span> <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> </div>
{#if r._events} {#if r._events}
<div class="journal-detail-section"> <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-type">{t('event.' + ev.eventType) || ev.eventType}</span>
<span class="suggestion-event-title">{ev.title}</span> <span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button> <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> </div>
{/each} {/each}
</div> </div>
@ -2002,7 +2160,7 @@
<div class="today-timeline"> <div class="today-timeline">
<h3>{t('today.timeline')}</h3> <h3>{t('today.timeline')}</h3>
{#each todayDashboard.events as ev} {#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-dot"></span>
<span class="timeline-title">{ev.title}</span> <span class="timeline-title">{ev.title}</span>
<span class="timeline-type">{eventLabel(ev.eventType)}</span> <span class="timeline-type">{eventLabel(ev.eventType)}</span>
@ -2029,7 +2187,7 @@
{:else} {:else}
<div class="activity-feed-events"> <div class="activity-feed-events">
{#each activityFeed as ev} {#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> <span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
<div class="activity-feed-body"> <div class="activity-feed-body">
<span class="activity-feed-title">{ev.title}</span> <span class="activity-feed-title">{ev.title}</span>
@ -2133,6 +2291,49 @@
</div> </div>
{/if} {/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} {#if showCreateAction}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateAction} on:keydown={onKeyActivate(cancelCreateAction)}> <div class="modal-overlay" role="button" tabindex="0" on:click|self={cancelCreateAction} on:keydown={onKeyActivate(cancelCreateAction)}>
<div class="modal"> <div class="modal">
@ -2410,11 +2611,7 @@
/* Worklog tab */ /* Worklog tab */
.worklog-tab { padding: 24px; } .worklog-tab { padding: 24px; }
.worklog-form { display: flex; gap: 8px; margin-bottom: 24px; align-items: center; } .worklog-toolbar { margin-bottom: 16px; }
.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-entry { padding: 12px 0; border-bottom: 1px solid #1a1a28; } .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; } .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; } .suggestion-summary { font-size: 14px; color: #e4e4ef; }
@ -2446,11 +2643,6 @@
.journal-screen { padding: 24px; overflow-y: auto; flex: 1; } .journal-screen { padding: 24px; overflow-y: auto; flex: 1; }
.journal-header { margin-bottom: 24px; } .journal-header { margin-bottom: 24px; }
.journal-header h2 { margin: 0 0 16px 0; } .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; } .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-total { font-size: 18px; font-weight: 700; color: #e4e4ef; width: 100%; margin-bottom: 4px; }
.summary-section { flex: 1; min-width: 200px; } .summary-section { flex: 1; min-width: 200px; }
@ -2491,7 +2683,7 @@
.journal-selected-node:hover { background: #2a2a4a; } .journal-selected-node:hover { background: #2a2a4a; }
.journal-node-clear { color: #8888a0; font-size: 14px; margin-left: 4px; } .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; } .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 */
.today-suggestions { margin-bottom: 24px; } .today-suggestions { margin-bottom: 24px; }
@ -2502,7 +2694,6 @@
/* Nav badge */ /* 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; } .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 */
.actions-tab { padding: 24px; } .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-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 { 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 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-group { margin-bottom: 12px; }
.form-row { display: flex; gap: 16px; margin-bottom: 12px; }
.form-group label { display: block; } .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 .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; } .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-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; } .sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; } .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> </style>

View File

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

View File

@ -156,13 +156,15 @@ export default {
'worklog.minutes': 'Мин', 'worklog.minutes': 'Мин',
'worklog.min': 'мин', 'worklog.min': 'мин',
'worklog.log': 'Записать', 'worklog.log': 'Записать',
'worklog.addEntry': 'Добавить запись',
'worklog.date': 'Дата',
'worklog.empty': 'Записей работы пока нет', 'worklog.empty': 'Записей работы пока нет',
'worklog.details': 'Детали', 'worklog.details': 'Детали',
'worklog.source': 'Источник', 'worklog.source': 'Источник',
'worklog.sourceSuggestion': 'Предложение activity', 'worklog.sourceSuggestion': 'Из предложения',
'worklog.sourceSuggestionNoEvents': 'Из предложения, но связанные события отсутствуют',
'worklog.sourceManual': 'Ручная запись', 'worklog.sourceManual': 'Ручная запись',
'worklog.suggestions': 'Предложения', 'worklog.sourceUnknown': 'Источник неизвестен',
'worklog.apply': 'Применить',
'worklog.suggestions': 'Предложения на сегодня', 'worklog.suggestions': 'Предложения на сегодня',
'worklog.apply': 'Применить', 'worklog.apply': 'Применить',
@ -231,9 +233,12 @@ export default {
'journal.path': 'Путь', 'journal.path': 'Путь',
'journal.byDay': 'По дням', 'journal.byDay': 'По дням',
'journal.byNode': 'По делам', 'journal.byNode': 'По делам',
'journal.includeChildren': 'С подзадачами', 'journal.includeChildren': 'Учитывать вложенные дела',
'journal.nodeSearch': 'Поиск дела...', 'journal.nodeSearch': 'Поиск дела...',
'journal.relatedEvents': 'Связанные события', 'journal.relatedEvents': 'Связанные события',
'journal.filterHeading': 'Фильтры',
'journal.exportHeading': 'Экспорт отчёта',
'journal.reset': 'Сбросить',
'suggest.title': 'Предложения на сегодня', 'suggest.title': 'Предложения на сегодня',
'suggest.apply': 'Записать', '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, 11: migration011,
12: migration012, 12: migration012,
13: migration013, 13: migration013,
14: migration014,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {

View File

@ -8,7 +8,6 @@ import (
"time" "time"
"verstak/internal/core/storage" "verstak/internal/core/storage"
"verstak/internal/core/util"
) )
// ReportFilter specifies which worklog entries to include. // ReportFilter specifies which worklog entries to include.
@ -33,6 +32,7 @@ type ReportRow struct {
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
Approximate bool `json:"approximate"` Approximate bool `json:"approximate"`
Billable bool `json:"billable"` Billable bool `json:"billable"`
Source string `json:"source"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` 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, 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, 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 FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id` + LEFT JOIN nodes n ON n.id = w.node_id` +
whereClause + whereClause +
@ -170,7 +170,7 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
var approxInt, billInt int var approxInt, billInt int
err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date, err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt, &r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr) &r.Source, &createdStr, &updatedStr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -349,7 +349,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) { func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary, 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, 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 FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id LEFT JOIN nodes n ON n.id = w.node_id
WHERE w.id = ?` WHERE w.id = ?`
@ -358,7 +358,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
var approxInt, billInt int var approxInt, billInt int
err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date, err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt, &r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr) &r.Source, &createdStr, &updatedStr)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -371,41 +371,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
// AddWithDate inserts a worklog entry with a specific date. // 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) { func (s *Service) AddWithDate(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*Entry, error) {
if nodeID == "" { return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
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. // UpdateDate updates the date of an entry.

View File

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