feat: journal UX overhaul — picker, export dialog, events, readability

- Sidebar i18n: added missing nav.journal to backend ru.json
- Export: SaveWorklogReport binding with native SaveFileDialog + os.WriteFile
- Filter: better IncludeChildren label with disabled tooltip
- Filter: renamed billable→К оплате, approximate→Тип времени with hints
- worklog_entry_events table (migration 013) linking entries to activity events
- Suggestion: EventIDs + Events details, expandable cards with timestamps
- Journal rows: expandable with details, source, linked events
- Contrast: improved readability for dates, timestamps, hover states
- i18n: added worklog.*, journal.*, suggest.* keys to ru.js/en.js
This commit is contained in:
mirivlad 2026-06-03 11:24:59 +08:00
parent d34100e2ed
commit 1472bb3e6f
13 changed files with 402 additions and 98 deletions

View File

@ -60,6 +60,21 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
min := estimateMinutes(bursts, spread, len(grp.events)) min := estimateMinutes(bursts, spread, len(grp.events))
conf, reason := confidence(bursts, spread, len(grp.events)) conf, reason := confidence(bursts, spread, len(grp.events))
eventIDs := make([]string, 0, len(grp.events))
evDetails := make([]activity.SuggestionDetail, 0, len(grp.events))
for _, e := range grp.events {
eventIDs = append(eventIDs, e.ID)
evDetails = append(evDetails, activity.SuggestionDetail{
ID: e.ID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
Title: e.Title,
CreatedAt: e.CreatedAt,
NodeID: e.NodeID,
})
}
suggestions = append(suggestions, activity.Suggestion{ suggestions = append(suggestions, activity.Suggestion{
NodeID: nodeID, NodeID: nodeID,
NodeTitle: grp.title, NodeTitle: grp.title,
@ -70,6 +85,8 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
Confidence: conf, Confidence: conf,
ConfidenceReason: reason, ConfidenceReason: reason,
TimeSpreadMin: spread, TimeSpreadMin: spread,
EventIDs: eventIDs,
Events: evDetails,
}) })
} }
@ -95,6 +112,12 @@ func (a *App) AcceptSuggestionWith(s activity.Suggestion, minutes int, date stri
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Link activity events to this worklog entry.
for _, eid := range s.EventIDs {
_, _ = a.db.Exec(
`INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid)
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry)) _ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0 mins := 0
if entry.Minutes != nil { if entry.Minutes != nil {

View File

@ -1,8 +1,13 @@
package main package main
import ( import (
syncsvc "verstak/internal/core/sync" "fmt"
"os"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/worklog" "verstak/internal/core/worklog"
syncsvc "verstak/internal/core/sync"
) )
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) { func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
@ -90,6 +95,94 @@ func buildWorklogFilter(dateFrom, dateTo, nodeID string, includeChildren bool, b
} }
} }
// SaveWorklogReport generates a worklog report and opens a SaveFileDialog.
func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, includeChildren bool, billableFilter, approxFilter string) (string, error) {
f := buildWorklogFilter(dateFrom, dateTo, nodeID, includeChildren, billableFilter, approxFilter)
var data []byte
var ext string
switch format {
case "csv":
s, err := a.worklog.ExportCSV(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".csv"
case "markdown":
s, err := a.worklog.ExportMarkdown(f)
if err != nil {
return "", err
}
data = []byte(s)
ext = ".md"
case "pdf":
var err error
data, err = a.worklog.ExportPDF(f)
if err != nil {
return "", err
}
ext = ".pdf"
default:
return "", fmt.Errorf("unknown format: %s", format)
}
from := dateFrom
if from == "" {
from = "all"
}
to := dateTo
if to == "" {
to = "all"
}
defaultName := fmt.Sprintf("verstak-worklog-%s--%s%s", from, to, ext)
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
DefaultFilename: defaultName,
Filters: []wailsruntime.FileFilter{
{DisplayName: format, Pattern: "*" + ext},
},
})
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("отменено пользователем")
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return "", fmt.Errorf("не удалось сохранить файл: %w", err)
}
return fmt.Sprintf("Отчёт сохранён: %s", path), nil
}
// GetWorklogEntryEvents returns activity events linked to a worklog entry.
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
rows, err := a.db.Query(
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
e.title, e.details_json, e.created_at
FROM activity_events e
JOIN worklog_entry_events wle ON wle.event_id = e.id
WHERE wle.entry_id = ?
ORDER BY e.created_at ASC`, entryID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []EventDTO
for rows.Next() {
var d EventDTO
if err := rows.Scan(&d.ID, &d.NodeID, &d.EventType, &d.TargetType,
&d.TargetID, &d.TargetPath, &d.Title, &d.DetailsJSON, &d.CreatedAt); err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// --- helpers --- // --- helpers ---
func toWorklogDTOs(list []worklog.Entry) []WorklogDTO { func toWorklogDTOs(list []worklog.Entry) []WorklogDTO {

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

View File

@ -43,6 +43,7 @@
let journalBillableFilter = 'all' let journalBillableFilter = 'all'
let journalApproxFilter = 'all' let journalApproxFilter = 'all'
let journalFilteredNodeTitle = '' let journalFilteredNodeTitle = ''
let journalStatusMsg = ''
let journalSearchQuery = '' let journalSearchQuery = ''
let journalSearchResults = [] let journalSearchResults = []
let journalShowResults = false let journalShowResults = false
@ -988,37 +989,38 @@
} }
} }
async function exportJournalCSV() { async function saveJournalReport(format) {
try { try {
const csv = await wailsCall('ExportWorklogCSV', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter) const msg = await wailsCall('SaveWorklogReport', format,
downloadFile('worklog.csv', csv, 'text/csv') journalDateFrom, journalDateTo, journalNodeID,
} catch (e) { console.error(e) } journalIncludeChildren, journalBillableFilter, journalApproxFilter)
} journalStatusMsg = msg
setTimeout(() => journalStatusMsg = '', 4000)
async function exportJournalMarkdown() { } catch (e) {
try { if (String(e).includes('отменено')) return
const md = await wailsCall('ExportWorklogMarkdown', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter) journalStatusMsg = 'Ошибка: ' + String(e)
downloadFile('worklog.md', md, 'text/markdown') setTimeout(() => journalStatusMsg = '', 6000)
} catch (e) { console.error(e) } }
}
async function exportJournalPDF() {
try {
const data = await wailsCall('ExportWorklogPDF', journalDateFrom, journalDateTo, journalNodeID, journalIncludeChildren, journalBillableFilter, journalApproxFilter)
let pdfBytes = data
if (typeof data === 'string') {
const bin = atob(data)
pdfBytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) pdfBytes[i] = bin.charCodeAt(i)
}
const from = journalDateFrom || 'all'
const to = journalDateTo || 'all'
downloadFile(`verstak-worklog-${from}--${to}.pdf`, pdfBytes, 'application/pdf')
} catch (e) { console.error(e) }
} }
let journalSearchTimer let journalSearchTimer
async function toggleJournalRow(r) {
r._expanded = !r._expanded
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 }
}
}
function formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
async function searchJournalNodes() { async function searchJournalNodes() {
const q = journalSearchQuery.trim() const q = journalSearchQuery.trim()
if (!q || q.length < 2) { if (!q || q.length < 2) {
@ -1187,10 +1189,6 @@
if (type === 'file_moved') return '→' if (type === 'file_moved') return '→'
return '•' return '•'
} }
function formatTime(str) {
if (!str) return ''
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
}
function formatDate(str) { function formatDate(str) {
if (!str) return '' if (!str) return ''
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str } try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
@ -1671,16 +1669,31 @@
<div class="worklog-tab-suggestions"> <div class="worklog-tab-suggestions">
<div class="suggestions-title">{t('worklog.suggestions')}</div> <div class="suggestions-title">{t('worklog.suggestions')}</div>
{#each suggestions.filter(s => s.nodeId === selectedNode.id) as s} {#each suggestions.filter(s => s.nodeId === selectedNode.id) as s}
<div class="suggestion-card"> <div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-info"> <div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<span class="suggestion-summary">{s.summary}</span> <div class="suggestion-info">
<span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span> <span class="suggestion-summary">{s.summary}</span>
</div> <span class="suggestion-meta">{s.suggestedMin} {t('worklog.min')} · {t('suggest.confidence.' + s.confidence)}</span>
<div class="suggestion-actions"> </div>
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}> <div class="suggestion-actions">
{t('worklog.apply')} <button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
</button> {t('worklog.apply')}
</button>
</div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<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>
</div>
{/each}
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -1751,28 +1764,37 @@
{/if} {/if}
</div> </div>
</label> </label>
<label class="checkbox-label"> <label class="checkbox-label" title={!journalNodeID ? t('journal.includeChildrenDisabledHint') : ''}>
<input type="checkbox" bind:checked={journalIncludeChildren} disabled={!journalNodeID} /> {#if journalNodeID}
<span>{t('journal.includeChildren')}</span> <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> </label>
<label><span class="label-text">{t('journal.billable')}</span> <label title={t('journal.billableHint')}>
<span class="label-text">{t('journal.billable')}</span>
<select bind:value={journalBillableFilter}> <select bind:value={journalBillableFilter}>
<option value="all">{t('common.all')}</option> <option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.billable')}</option> <option value="yes">{t('journal.billableYes')}</option>
<option value="no">{t('common.no')}</option> <option value="no">{t('journal.billableNo')}</option>
</select> </select>
</label> </label>
<label><span class="label-text">{t('journal.approximate')}</span> <label title={t('journal.approxHint')}>
<span class="label-text">{t('journal.approx')}</span>
<select bind:value={journalApproxFilter}> <select bind:value={journalApproxFilter}>
<option value="all">{t('common.all')}</option> <option value="all">{t('common.all')}</option>
<option value="yes">{t('journal.approximate')}</option> <option value="no">{t('journal.approxExact')}</option>
<option value="no">{t('common.no')}</option> <option value="yes">{t('journal.approxEstimated')}</option>
</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={exportJournalCSV}>{t('journal.exportCSV')}</button> <button class="btn btn-sm" on:click={() => saveJournalReport('csv')}>{t('journal.exportCSV')}</button>
<button class="btn btn-sm" on:click={exportJournalMarkdown}>{t('journal.exportMarkdown')}</button> <button class="btn btn-sm" on:click={() => saveJournalReport('markdown')}>{t('journal.exportMarkdown')}</button>
<button class="btn btn-sm" on:click={exportJournalPDF}>PDF</button> <button class="btn btn-sm" on:click={() => saveJournalReport('pdf')}>PDF</button>
{#if journalStatusMsg}
<span class="journal-status-msg">{journalStatusMsg}</span>
{/if}
</div> </div>
</div> </div>
@ -1780,18 +1802,33 @@
<div class="journal-suggestions"> <div class="journal-suggestions">
<div class="suggestions-title">{t('suggest.title')}</div> <div class="suggestions-title">{t('suggest.title')}</div>
{#each suggestions as s} {#each suggestions as s}
<div class="suggestion-card"> <div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-info"> <div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button> <div class="suggestion-info">
<span class="suggestion-summary">{s.summary}</span> <button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span> <span class="suggestion-summary">{s.summary}</span>
</div> <span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
<div class="suggestion-actions"> </div>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480" <div class="suggestion-actions">
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} /> <input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
<span class="suggestion-min-label">{t('suggest.minutes')}</span> on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<button class="btn btn-sm btn-primary" on:click={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button> <span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
</div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<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>
</div>
{/each}
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -1831,21 +1868,52 @@
<th>{t('journal.path')}</th> <th>{t('journal.path')}</th>
<th>{t('worklog.minutes')}</th> <th>{t('worklog.minutes')}</th>
<th>{t('journal.billable')}</th> <th>{t('journal.billable')}</th>
<th>{t('journal.approximate')}</th> <th>{t('journal.approx')}</th>
<th>{t('common.date')}</th> <th>{t('common.date')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each journalRows as r} {#each journalRows as r}
<tr> <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>{r.summary}</td> <td class="journal-summary-cell">{r.summary}</td>
<td><button class="link-btn" on:click={() => 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>
<td class="journal-min-cell">{r.minutes}</td> <td class="journal-min-cell">{r.minutes}</td>
<td>{#if r.billable}{/if}</td> <td class="journal-bool-cell">{#if r.billable}{/if}</td>
<td>{#if r.approximate}~{/if}</td> <td class="journal-bool-cell">{#if r.approximate}~{/if}</td>
<td class="journal-date-cell">{r.date}</td> <td class="journal-date-cell">{r.date}</td>
</tr> </tr>
{#if r._expanded}
<tr class="journal-row-detail">
<td colspan="7">
<div class="journal-detail-body">
{#if r.details}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('worklog.details')}</span>
<p>{r.details}</p>
</div>
{/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>
</div>
{#if r._events}
<div class="journal-detail-section">
<span class="journal-detail-label">{t('journal.relatedEvents')}</span>
{#each r._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>
</div>
{/each}
</div>
{/if}
</div>
</td>
</tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
@ -1871,18 +1939,33 @@
<div class="today-suggestions"> <div class="today-suggestions">
<div class="suggestions-title">{t('suggest.title')}</div> <div class="suggestions-title">{t('suggest.title')}</div>
{#each suggestions as s} {#each suggestions as s}
<div class="suggestion-card"> <div class="suggestion-card" class:expanded={s._expanded}>
<div class="suggestion-info"> <div class="suggestion-main" on:click={() => s._expanded = !s._expanded} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && (s._expanded = !s._expanded)}>
<button class="suggestion-node link-btn" on:click={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button> <div class="suggestion-info">
<span class="suggestion-summary">{s.summary}</span> <button class="suggestion-node link-btn" on:click|stopPropagation={() => openNodeById(s.nodeId)}>{s.nodeTitle}</button>
<span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span> <span class="suggestion-summary">{s.summary}</span>
</div> <span class="suggestion-confidence" class:low={s.confidence === 'low'} class:medium={s.confidence === 'medium'} class:high={s.confidence === 'high'}>{t('suggest.confidence.' + s.confidence)}</span>
<div class="suggestion-actions"> </div>
<input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480" <div class="suggestion-actions">
on:change={(e) => s.suggestedMin = parseInt(e.target.value)} /> <input type="number" class="suggestion-min-input" value={s.suggestedMin} min="1" max="480"
<span class="suggestion-min-label">{t('suggest.minutes')}</span> on:change|stopPropagation={(e) => s.suggestedMin = parseInt(e.target.value)} />
<button class="btn btn-sm btn-primary" on:click={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button> <span class="suggestion-min-label">{t('suggest.minutes')}</span>
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
</div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0}
<div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev}
<div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<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>
</div>
{/each}
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -2336,6 +2419,14 @@
.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; }
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; } .suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
.suggestion-card.expanded { border-color: #3a3a5c; }
.suggestion-detail { padding: 0 12px 10px; border-top: 1px solid #2a2a3c; }
.suggestion-detail-title { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 0 4px; }
.suggestion-detail-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; color: #b0b0c0; }
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Suggestion cards */ /* Suggestion cards */
.suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; } .suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; }
@ -2369,12 +2460,27 @@
.summary-count { color: #8888a0; } .summary-count { color: #8888a0; }
.journal-table-wrap { overflow-x: auto; } .journal-table-wrap { overflow-x: auto; }
.journal-table { width: 100%; border-collapse: collapse; font-size: 13px; } .journal-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.journal-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #2a2a3c; color: #8888a0; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; } .journal-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #2a2a3c; color: #b0b0c8; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; }
.journal-table td { padding: 8px 12px; border-bottom: 1px solid #1a1a28; color: #e4e4ef; } .journal-table td { padding: 8px 12px; border-bottom: 1px solid #1a1a28; color: #e4e4ef; }
.journal-table tr:hover td { background: #1e1e32; }
.journal-table .link-btn { color: #a5b4fc; } .journal-table .link-btn { color: #a5b4fc; }
.journal-path-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8888a0; font-size: 12px; } .journal-path-cell { max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a0b8; font-size: 12px; }
.journal-min-cell { text-align: right; font-variant-numeric: tabular-nums; } .journal-min-cell { text-align: right; font-variant-numeric: tabular-nums; }
.journal-date-cell { color: #8888a0; white-space: nowrap; } .journal-date-cell { color: #b0b0c0; white-space: nowrap; }
.journal-bool-cell { text-align: center; color: #a0a0b8; }
.journal-row { cursor: pointer; }
.journal-row:hover td { background: #1e1e32; }
.journal-row.expanded td { background: #1a1a30; border-bottom: none; }
.journal-summary-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.journal-row-detail td { padding: 0 12px 12px; background: #16162a; }
.journal-detail-body { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
.journal-detail-section { font-size: 13px; }
.journal-detail-label { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 4px; }
.journal-detail-section p { margin: 0; color: #c0c0d0; }
.journal-event-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; }
.journal-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
.journal-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
.journal-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.journal-node-picker input[type="text"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; width: 240px; } .journal-node-picker input[type="text"] { padding: 6px 10px; border: 1px solid #2a2a3c; background: #13131f; color: #e4e4ef; border-radius: 4px; font-size: 13px; font-family: inherit; width: 240px; }
.journal-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 6px; margin-top: 4px; max-height: 240px; overflow-y: auto; min-width: 260px; } .journal-search-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 50; background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 6px; margin-top: 4px; max-height: 240px; overflow-y: auto; min-width: 260px; }
.journal-search-item { display: block; width: 100%; padding: 8px 12px; border: none; background: transparent; color: #e4e4ef; cursor: pointer; font-family: inherit; font-size: 13px; text-align: left; } .journal-search-item { display: block; width: 100%; padding: 8px 12px; border: none; background: transparent; color: #e4e4ef; cursor: pointer; font-family: inherit; font-size: 13px; text-align: left; }
@ -2384,6 +2490,8 @@
.journal-selected-node { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: #1e1e3a; border: 1px solid #3a3a5c; border-radius: 4px; font-size: 13px; color: #a5b4fc; white-space: nowrap; font-family: inherit; } .journal-selected-node { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; background: #1e1e3a; border: 1px solid #3a3a5c; border-radius: 4px; font-size: 13px; color: #a5b4fc; white-space: nowrap; font-family: inherit; }
.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; }
.disabled-hint { opacity: 0.5; }
/* Today suggestions */ /* Today suggestions */
.today-suggestions { margin-bottom: 24px; } .today-suggestions { margin-bottom: 24px; }

View File

@ -123,9 +123,20 @@ export default {
'worklog.suggestions': 'Suggestions for today', 'worklog.suggestions': 'Suggestions for today',
'worklog.apply': 'Apply', 'worklog.apply': 'Apply',
'worklog.title': 'Work Log',
'worklog.whatDone': 'What was done',
'worklog.minutes': 'Min',
'worklog.min': 'min',
'worklog.log': 'Log',
'worklog.empty': 'No work logged yet',
'worklog.details': 'Details',
'worklog.source': 'Source',
'worklog.sourceSuggestion': 'Activity suggestion',
'worklog.sourceManual': 'Manual entry',
'common.all': 'All', 'common.all': 'All',
'common.no': 'No', 'common.no': 'No',
'common.open': 'Open',
'common.date': 'Date', 'common.date': 'Date',
'common.search': 'Search', 'common.search': 'Search',
@ -140,13 +151,21 @@ export default {
'journal.exportCSV': 'CSV', 'journal.exportCSV': 'CSV',
'journal.exportMarkdown': 'Markdown', 'journal.exportMarkdown': 'Markdown',
'journal.billable': 'Billable', 'journal.billable': 'Billable',
'journal.approximate': 'Approx', 'journal.billableYes': 'Billable',
'journal.billableNo': 'Not billable',
'journal.billableHint': 'Billable — can be included in client invoice',
'journal.approx': 'Time type',
'journal.approxExact': 'Exact',
'journal.approxEstimated': 'Estimated',
'journal.approxHint': 'Estimated — time suggested by the system or entered as approximate',
'journal.includeChildrenDisabledHint': 'Only works when a case/client/project is selected',
'journal.node': 'Case', 'journal.node': 'Case',
'journal.path': 'Path', 'journal.path': 'Path',
'journal.byDay': 'By day', 'journal.byDay': 'By day',
'journal.byNode': 'By case', 'journal.byNode': 'By case',
'journal.includeChildren': 'Include subtasks', 'journal.includeChildren': 'Include subtasks',
'journal.nodeSearch': 'Search case...', 'journal.nodeSearch': 'Search case...',
'journal.relatedEvents': 'Related events',
'suggest.title': 'Suggestions', 'suggest.title': 'Suggestions',
'suggest.apply': 'Log', 'suggest.apply': 'Log',
@ -158,4 +177,5 @@ export default {
'suggest.minutes': 'min', 'suggest.minutes': 'min',
'suggest.edit': 'Edit', 'suggest.edit': 'Edit',
'suggest.noSuggestions': 'No suggestions', 'suggest.noSuggestions': 'No suggestions',
'suggest.detectedEvents': 'What was detected',
} }

View File

@ -50,6 +50,7 @@ export default {
'common.run': 'Запустить', 'common.run': 'Запустить',
'common.test': 'Test', 'common.test': 'Test',
'common.all': 'Все', 'common.all': 'Все',
'common.open': 'Открыть',
'common.no': 'Нет', 'common.no': 'Нет',
'common.date': 'Дата', 'common.date': 'Дата',
'common.search': 'Найти', 'common.search': 'Найти',
@ -156,6 +157,12 @@ export default {
'worklog.min': 'мин', 'worklog.min': 'мин',
'worklog.log': 'Записать', 'worklog.log': 'Записать',
'worklog.empty': 'Записей работы пока нет', 'worklog.empty': 'Записей работы пока нет',
'worklog.details': 'Детали',
'worklog.source': 'Источник',
'worklog.sourceSuggestion': 'Предложение activity',
'worklog.sourceManual': 'Ручная запись',
'worklog.suggestions': 'Предложения',
'worklog.apply': 'Применить',
'worklog.suggestions': 'Предложения на сегодня', 'worklog.suggestions': 'Предложения на сегодня',
'worklog.apply': 'Применить', 'worklog.apply': 'Применить',
@ -211,14 +218,22 @@ export default {
'journal.total': 'Всего', 'journal.total': 'Всего',
'journal.exportCSV': 'CSV', 'journal.exportCSV': 'CSV',
'journal.exportMarkdown': 'Markdown', 'journal.exportMarkdown': 'Markdown',
'journal.billable': 'Оплачиваемое', 'journal.billable': 'К оплате',
'journal.approximate': 'Примерно', 'journal.billableYes': 'К оплате',
'journal.billableNo': 'Не к оплате',
'journal.billableHint': 'К оплате — можно включать в счёт клиенту',
'journal.approx': 'Тип времени',
'journal.approxExact': 'Точное',
'journal.approxEstimated': 'Оценочное',
'journal.approxHint': 'Оценочное — время предложено системой или введено как примерное',
'journal.includeChildrenDisabledHint': 'Работает только при выбранном деле/клиенте/проекте',
'journal.node': 'Дело', 'journal.node': 'Дело',
'journal.path': 'Путь', 'journal.path': 'Путь',
'journal.byDay': 'По дням', 'journal.byDay': 'По дням',
'journal.byNode': 'По делам', 'journal.byNode': 'По делам',
'journal.includeChildren': 'С подзадачами', 'journal.includeChildren': 'С подзадачами',
'journal.nodeSearch': 'Поиск дела...', 'journal.nodeSearch': 'Поиск дела...',
'journal.relatedEvents': 'Связанные события',
'suggest.title': 'Предложения на сегодня', 'suggest.title': 'Предложения на сегодня',
'suggest.apply': 'Записать', 'suggest.apply': 'Записать',
@ -230,6 +245,7 @@ export default {
'suggest.minutes': 'мин', 'suggest.minutes': 'мин',
'suggest.edit': 'Изменить', 'suggest.edit': 'Изменить',
'suggest.noSuggestions': 'Нет предложений для журнала', 'suggest.noSuggestions': 'Нет предложений для журнала',
'suggest.detectedEvents': 'Что обнаружено',
'activity.title': 'Активность', 'activity.title': 'Активность',
'activity.empty': 'Активность пока не зафиксирована', 'activity.empty': 'Активность пока не зафиксирована',

View File

@ -7,16 +7,29 @@ const (
ConfidenceHigh = "high" ConfidenceHigh = "high"
) )
// SuggestionDetail is a lightweight event summary for suggestion display.
type SuggestionDetail struct {
ID string `json:"id"`
EventType string `json:"eventType"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
Title string `json:"title"`
CreatedAt string `json:"createdAt"`
NodeID string `json:"nodeId"`
}
// Suggestion represents a suggested worklog entry derived from today's activity. // Suggestion represents a suggested worklog entry derived from today's activity.
type Suggestion struct { type Suggestion struct {
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"` NodeTitle string `json:"nodeTitle"`
Summary string `json:"summary"` Summary string `json:"summary"`
SuggestedMin int `json:"suggestedMin"` SuggestedMin int `json:"suggestedMin"`
EventCount int `json:"eventCount"` EventCount int `json:"eventCount"`
NodeKind string `json:"nodeKind"` NodeKind string `json:"nodeKind"`
Confidence string `json:"confidence"` Confidence string `json:"confidence"`
ConfidenceReason string `json:"confidenceReason"` ConfidenceReason string `json:"confidenceReason"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event TimeSpreadMin int `json:"timeSpreadMin"` // minutes between first and last event
EventIDs []string `json:"eventIds"`
Events []SuggestionDetail `json:"events,omitempty"`
} }

View File

@ -0,0 +1,12 @@
package storage
// migration013 — worklog_entry_events table linking entries to activity events.
const migration013 = `
CREATE TABLE IF NOT EXISTS worklog_entry_events (
entry_id TEXT NOT NULL REFERENCES worklog_entries(id) ON DELETE CASCADE,
event_id TEXT NOT NULL,
PRIMARY KEY (entry_id, event_id)
);
CREATE INDEX IF NOT EXISTS idx_wle_events ON worklog_entry_events(event_id);
`

View File

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

View File

@ -7,6 +7,17 @@
"nav.recipes": "Recipes", "nav.recipes": "Recipes",
"nav.documents": "Documents", "nav.documents": "Documents",
"nav.archive": "Archive", "nav.archive": "Archive",
"nav.journal": "Work Log",
"nav.sections": "Sections",
"nav.cases": "Cases",
"nav.noCases": "No cases",
"nav.sync": "Sync",
"nav.syncSettings": "Sync settings",
"nav.syncNow": "Sync now",
"nav.selectPrompt": "Select a section or case",
"nav.brand": "Verstak",
"nav.createNode": "Create node",
"nav.moveToRoot": "Move to root",
"tab.overview": "Overview", "tab.overview": "Overview",
"tab.notes": "Notes", "tab.notes": "Notes",

View File

@ -10,6 +10,7 @@
"nav.sections": "Разделы", "nav.sections": "Разделы",
"nav.cases": "Дела", "nav.cases": "Дела",
"nav.noCases": "Нет дел", "nav.noCases": "Нет дел",
"nav.journal": "Журнал",
"nav.sync": "Синхронизация", "nav.sync": "Синхронизация",
"nav.syncSettings": "Настройки синхронизации", "nav.syncSettings": "Настройки синхронизации",
"nav.syncNow": "Синхронизировать", "nav.syncNow": "Синхронизировать",
@ -391,6 +392,8 @@
"nav.noNodes": "Нет узлов", "nav.noNodes": "Нет узлов",
"nav.openFolder": "Открыть папку", "nav.openFolder": "Открыть папку",
"nav.createInside": "Создать внутри", "nav.createInside": "Создать внутри",
"nav.createNode": "Создать элемент",
"nav.moveToRoot": "Переместить в корень",
"template.folder": "Папка", "template.folder": "Папка",
"template.project": "Проект", "template.project": "Проект",