feat: edit suggestions before accepting worklog

This commit is contained in:
mirivlad 2026-06-05 00:53:13 +08:00
parent eb6a861310
commit 02d68ca3f4
10 changed files with 92 additions and 21 deletions

View File

@ -8,8 +8,8 @@ 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"
"verstak/internal/core/worklog"
) )
// GetSuggestions analyzes today's activity and returns conservative suggestions. // GetSuggestions analyzes today's activity and returns conservative suggestions.
@ -126,6 +126,12 @@ func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string,
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction. // AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues. // eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) { func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
return a.AcceptSuggestionFull(nodeID, summary, "", date, minutes, true, false, eventIDsJSON)
}
// AcceptSuggestionFull creates a worklog entry from an edited suggestion and links events in a single transaction.
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionFull(nodeID, summary, details, date string, minutes int, approximate, billable bool, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
@ -159,7 +165,7 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
} }
defer tx.Rollback() defer tx.Rollback()
entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, "", d, minutes, true, false, worklog.SourceSuggestion) entry, err := a.worklog.AddWithSourceTx(tx, nodeID, summary, details, d, minutes, approximate, billable, worklog.SourceSuggestion)
if err != nil { if err != nil {
return nil, fmt.Errorf("create entry: %w", err) return nil, fmt.Errorf("create entry: %w", err)
} }
@ -190,18 +196,7 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
} }
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry)) _ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0 return entryToDTO(entry), nil
if entry.Minutes != nil {
mins = *entry.Minutes
}
return &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
Date: entry.Date,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
} }
// HideSuggestion marks a suggestion as hidden for the session. // HideSuggestion marks a suggestion as hidden for the session.

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,7 +16,7 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-Cc1HprFt.js"></script> <script type="module" crossorigin src="/assets/main-Cyhj7TEH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css"> <link rel="stylesheet" crossorigin href="/assets/main-DAyIHTpH.css">
</head> </head>
<body> <body>

View File

@ -392,3 +392,31 @@ func TestApplyRemoteWorklogUpdate(t *testing.T) {
t.Fatalf("remote updated minutes/flags = %#v", got) t.Fatalf("remote updated minutes/flags = %#v", got)
} }
} }
func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
eventIDsJSON, _ := json.Marshal([]string{eventID})
dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON))
if err != nil {
t.Fatalf("AcceptSuggestionFull: %v", err)
}
if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" {
t.Fatalf("dto = %#v", dto)
}
if dto.Minutes != 55 || dto.Approximate || !dto.Billable {
t.Fatalf("dto minutes/flags = %#v", dto)
}
if dto.Source != worklog.SourceSuggestion {
t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion)
}
if n := countLinked(t, app, dto.ID); n != 1 {
t.Fatalf("linked events = %d, want 1", n)
}
}

View File

@ -88,6 +88,7 @@
let wlModalBillable = false let wlModalBillable = false
let wlModalApprox = false let wlModalApprox = false
let editingWorklogEntry = null let editingWorklogEntry = null
let acceptingSuggestion = null
let suggestions = [] let suggestions = []
let suggestionCount = 0 let suggestionCount = 0
let showCreateNode = false let showCreateNode = false
@ -968,6 +969,7 @@
// ===== Worklog ===== // ===== Worklog =====
function openWorklogModal(entry = null) { function openWorklogModal(entry = null) {
editingWorklogEntry = entry editingWorklogEntry = entry
acceptingSuggestion = null
wlModalSummary = entry ? entry.summary : '' wlModalSummary = entry ? entry.summary : ''
wlModalMinutes = entry ? String(entry.minutes || '') : '' wlModalMinutes = entry ? String(entry.minutes || '') : ''
wlModalDate = entry ? (entry.date || '') : '' wlModalDate = entry ? (entry.date || '') : ''
@ -980,6 +982,7 @@
function closeWorklogModal() { function closeWorklogModal() {
showWorklogModal = false showWorklogModal = false
editingWorklogEntry = null editingWorklogEntry = null
acceptingSuggestion = null
} }
async function refreshWorklogViews(nodeID = '') { async function refreshWorklogViews(nodeID = '') {
@ -995,9 +998,13 @@
async function submitWorklogModal() { async function submitWorklogModal() {
const mins = parseInt(wlModalMinutes, 10) const mins = parseInt(wlModalMinutes, 10)
if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return if (!wlModalSummary.trim() || isNaN(mins) || mins <= 0) return
if (!editingWorklogEntry && !selectedNode) return if (!acceptingSuggestion && !editingWorklogEntry && !selectedNode) return
try { try {
if (editingWorklogEntry) { if (acceptingSuggestion) {
const eventIdsJSON = JSON.stringify(extractEventIds(acceptingSuggestion))
await wailsCall('AcceptSuggestionFull', acceptingSuggestion.nodeId, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable, eventIdsJSON)
await refreshAfterSuggestion()
} else if (editingWorklogEntry) {
await wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable) await wailsCall('UpdateWorklogEntry', editingWorklogEntry.id, wlModalSummary.trim(), wlModalDetails, wlModalDate, mins, wlModalApprox, wlModalBillable)
await refreshWorklogViews(editingWorklogEntry.nodeId) await refreshWorklogViews(editingWorklogEntry.nodeId)
} else { } else {
@ -1010,6 +1017,7 @@
} }
showWorklogModal = false showWorklogModal = false
editingWorklogEntry = null editingWorklogEntry = null
acceptingSuggestion = null
} }
function deleteWorklogEntry(entry) { function deleteWorklogEntry(entry) {
@ -1040,6 +1048,18 @@
} }
} }
function openSuggestionWorklogModal(s) {
acceptingSuggestion = s
editingWorklogEntry = null
wlModalSummary = s.summary || ''
wlModalMinutes = String(s.suggestedMin || '')
wlModalDate = ''
wlModalDetails = ''
wlModalBillable = false
wlModalApprox = true
showWorklogModal = true
}
function extractEventIds(s) { function extractEventIds(s) {
if (s.eventIds && s.eventIds.length) return s.eventIds if (s.eventIds && s.eventIds.length) return s.eventIds
if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean) if (s.events && s.events.length) return s.events.map(ev => ev.id).filter(Boolean)
@ -1856,6 +1876,9 @@
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span> <span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
</div> </div>
<div class="suggestion-actions"> <div class="suggestion-actions">
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>
{t('suggest.edit')}
</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}> <button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
{t('worklog.apply')} {t('worklog.apply')}
</button> </button>
@ -2053,6 +2076,7 @@
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480" <input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} /> on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span> <span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button> <button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
</div> </div>
</div> </div>
@ -2211,6 +2235,7 @@
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480" <input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} /> on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<span class="suggestion-min-label">{t('suggest.minutes')}</span> <span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button> <button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
</div> </div>
</div> </div>
@ -2400,7 +2425,7 @@
{#if showWorklogModal} {#if showWorklogModal}
<div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}> <div class="modal-overlay" role="button" tabindex="0" on:click|self={closeWorklogModal} on:keydown={onKeyActivate(closeWorklogModal)}>
<div class="modal modal-worklog"> <div class="modal modal-worklog">
<h3>{editingWorklogEntry ? t('worklog.editEntry') : t('worklog.addEntry')}</h3> <h3>{acceptingSuggestion ? t('worklog.acceptSuggestion') : editingWorklogEntry ? t('worklog.editEntry') : t('worklog.addEntry')}</h3>
<div class="form-group"> <div class="form-group">
<label><span class="label-text">{t('worklog.date')}</span> <label><span class="label-text">{t('worklog.date')}</span>
<input type="date" bind:value={wlModalDate} /> <input type="date" bind:value={wlModalDate} />

View File

@ -149,6 +149,7 @@ export default {
'worklog.min': 'min', 'worklog.min': 'min',
'worklog.log': 'Log', 'worklog.log': 'Log',
'worklog.addEntry': 'Add entry', 'worklog.addEntry': 'Add entry',
'worklog.acceptSuggestion': 'Accept suggestion',
'worklog.editEntry': 'Edit entry', 'worklog.editEntry': 'Edit entry',
'worklog.deleteEntry': 'Delete entry', 'worklog.deleteEntry': 'Delete entry',
'worklog.deleteConfirm': 'Delete this work entry? Related events stay in activity, but their link to this entry will be removed.', 'worklog.deleteConfirm': 'Delete this work entry? Related events stay in activity, but their link to this entry will be removed.',

View File

@ -158,6 +158,7 @@ export default {
'worklog.min': 'мин', 'worklog.min': 'мин',
'worklog.log': 'Записать', 'worklog.log': 'Записать',
'worklog.addEntry': 'Добавить запись', 'worklog.addEntry': 'Добавить запись',
'worklog.acceptSuggestion': 'Принять предложение',
'worklog.editEntry': 'Редактировать запись', 'worklog.editEntry': 'Редактировать запись',
'worklog.deleteEntry': 'Удалить запись', 'worklog.deleteEntry': 'Удалить запись',
'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.', 'worklog.deleteConfirm': 'Удалить эту запись работы? Связанные события останутся в активности, но связь с записью будет удалена.',

View File

@ -94,6 +94,14 @@ export function DeleteWorklogEntry(arg1) {
return window['go']['main']['App']['DeleteWorklogEntry'](arg1); return window['go']['main']['App']['DeleteWorklogEntry'](arg1);
} }
export function AcceptSuggestionWith(arg1, arg2, arg3, arg4, arg5) {
return window['go']['main']['App']['AcceptSuggestionWith'](arg1, arg2, arg3, arg4, arg5);
}
export function AcceptSuggestionFull(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8) {
return window['go']['main']['App']['AcceptSuggestionFull'](arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8);
}
export function Search(arg1) { export function Search(arg1) {
return window['go']['main']['App']['Search'](arg1); return window['go']['main']['App']['Search'](arg1);
} }

View File

@ -162,6 +162,14 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.tab', 'Журнал') await clickText(cdp, '.tab', 'Журнал')
await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible') await assertText(cdp, 'Manual smoke entry', 'worklog: entry visible')
await clickText(cdp, '.worklog-tab-suggestions .suggestion-actions .btn', 'Изменить')
await waitForSelector(cdp, '.modal-worklog')
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke accepted suggestion')
await setInputValue(cdp, '.modal-worklog input[type="number"]', '35')
await setInputValue(cdp, '.modal-worklog textarea', 'Accepted after manual edit')
await clickText(cdp, '.modal-worklog .btn', 'Сохранить')
await waitForGone(cdp, '.modal-worklog')
await assertText(cdp, 'GUI smoke accepted suggestion', 'worklog: edited suggestion accepted')
await clickText(cdp, '.worklog-toolbar .btn', 'Добавить запись') await clickText(cdp, '.worklog-toolbar .btn', 'Добавить запись')
await waitForSelector(cdp, '.modal-worklog') await waitForSelector(cdp, '.modal-worklog')
await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog') await setInputValue(cdp, '.modal-worklog input[type="text"]', 'GUI smoke worklog')
@ -768,6 +776,11 @@ function wailsMockSource() {
events, events,
}], }],
AcceptSuggestionWith: async () => true, AcceptSuggestionWith: async () => true,
AcceptSuggestionFull: async (nodeId, summary, details, date, minutes, approximate, billable) => {
const entry = { id: 'wl-suggestion-' + Date.now(), nodeId, summary, details, date: date || '2026-06-04', minutes, approximate, billable, source: 'suggestion', createdAt: now };
state.worklog[nodeId] = [...(state.worklog[nodeId] || []), entry];
return clone(entry);
},
ListWorklogReport: async () => clone(state.worklog['node-project'].map((entry) => ({ ...entry, nodeTitle: 'Smoke Project', nodePath: '/Smoke Project', _hasEvents: false }))), ListWorklogReport: async () => clone(state.worklog['node-project'].map((entry) => ({ ...entry, nodeTitle: 'Smoke Project', nodePath: '/Smoke Project', _hasEvents: false }))),
WorklogReportSummary: async () => ({ totalMinutes: 45, totalEntries: 1, byDay: [{ label: '2026-06-04', minutes: 45, count: 1 }], byNode: [{ label: 'Smoke Project', minutes: 45, count: 1 }] }), WorklogReportSummary: async () => ({ totalMinutes: 45, totalEntries: 1, byDay: [{ label: '2026-06-04', minutes: 45, count: 1 }], byNode: [{ label: 'Smoke Project', minutes: 45, count: 1 }] }),
GetWorklogEntryEvents: async () => [], GetWorklogEntryEvents: async () => [],