feat: aggregate journals across node subtrees

This commit is contained in:
mirivlad 2026-06-05 12:37:25 +08:00
parent 23f517dee3
commit 10b287de7b
14 changed files with 265 additions and 22 deletions

View File

@ -197,6 +197,7 @@ type WorklogDTO struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle,omitempty"` NodeTitle string `json:"nodeTitle,omitempty"`
NodePath string `json:"nodePath,omitempty"`
Summary string `json:"summary"` Summary string `json:"summary"`
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
@ -217,6 +218,7 @@ type SearchResultDTO struct {
type EventDTO struct { type EventDTO struct {
ID string `json:"id"` ID string `json:"id"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
NodePath string `json:"nodePath,omitempty"`
EventType string `json:"eventType"` EventType string `json:"eventType"`
TargetType string `json:"targetType"` TargetType string `json:"targetType"`
TargetID string `json:"targetId"` TargetID string `json:"targetId"`

View File

@ -157,7 +157,7 @@ func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
} }
result := make([]EventDTO, len(events)) result := make([]EventDTO, len(events))
for i, e := range events { for i, e := range events {
result[i] = toEventDTO(e) result[i] = a.eventDTOWithPath(e)
} }
return result, nil return result, nil
} }
@ -166,13 +166,13 @@ func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO,
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
events, err := a.activity.ListByNode(nodeID, limit, offset) events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]EventDTO, len(events)) result := make([]EventDTO, len(events))
for i, e := range events { for i, e := range events {
result[i] = toEventDTO(e) result[i] = a.eventDTOWithPath(e)
} }
return result, nil return result, nil
} }
@ -185,3 +185,38 @@ func (a *App) CountActivityByNode(nodeID string) (int, error) {
} }
var _ = syncsvc.EntityNode var _ = syncsvc.EntityNode
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
rows, err := a.db.Query(
`WITH RECURSIVE subtree(id) AS (
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
WHERE n.deleted_at IS NULL
)
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
e.title, COALESCE(e.metadata,'{}'), e.created_at
FROM activity_events e
JOIN subtree s ON s.id = e.node_id
ORDER BY e.created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []activity.Event
for rows.Next() {
var e activity.Event
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
dto := toEventDTO(e)
dto.NodePath = a.nodes.Path(e.NodeID)
return dto
}

View File

@ -35,6 +35,16 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
} }
} }
} }
rows, err = a.db.Query(`SELECT DISTINCT event_id FROM worklog_dismissed_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
@ -90,6 +100,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
Title: e.Title, Title: e.Title,
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
NodeID: e.NodeID, NodeID: e.NodeID,
NodePath: a.nodes.Path(e.NodeID),
}) })
} }
@ -115,6 +126,38 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
return suggestions, nil return suggestions, nil
} }
func (a *App) DismissSuggestion(nodeID, eventIDsJSON string) error {
if err := a.requireVault(); err != nil {
return err
}
var eventIDs []string
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
return fmt.Errorf("unmarshal eventIDs: %w", err)
}
if len(eventIDs) == 0 {
return fmt.Errorf("eventIDs required")
}
now := time.Now().UTC().Format(time.RFC3339)
tx, err := a.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, eventID := range eventIDs {
var n int
if err := tx.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, nodeID).Scan(&n); err != nil {
return fmt.Errorf("check event %s: %w", eventID, err)
}
if n == 0 {
return fmt.Errorf("event %s not found for node", eventID)
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO worklog_dismissed_events(event_id,node_id,created_at) VALUES(?,?,?)`, eventID, nodeID, now); err != nil {
return err
}
}
return tx.Commit()
}
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper). // AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) { func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {

View File

@ -14,11 +14,15 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
if err := a.requireVault(); err != nil { if err := a.requireVault(); err != nil {
return nil, err return nil, err
} }
list, err := a.worklog.ListByNode(nodeID) rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toWorklogDTOs(list), nil result := make([]WorklogDTO, 0, len(rows))
for _, row := range rows {
result = append(result, reportRowToWorklogDTO(row))
}
return result, nil
} }
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) { func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
@ -264,3 +268,20 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"), CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
} }
} }
func reportRowToWorklogDTO(r worklog.ReportRow) WorklogDTO {
return WorklogDTO{
ID: r.ID,
NodeID: r.NodeID,
NodeTitle: r.NodeTitle,
NodePath: r.NodePath,
Summary: r.Summary,
Minutes: r.Minutes,
Date: r.Date,
Details: r.Details,
Approximate: r.Approximate,
Billable: r.Billable,
Source: r.Source,
CreatedAt: r.CreatedAt,
}
}

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

@ -19,8 +19,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BQHjHDrT.js"></script> <script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-DfazBFdN.css"> <link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -345,6 +345,112 @@ func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
} }
} }
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
app, _ := setupTestApp(t)
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
if err != nil {
t.Fatalf("create parent: %v", err)
}
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
if err != nil {
t.Fatalf("create child: %v", err)
}
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
if err != nil {
t.Fatalf("CreateWorklogFull: %v", err)
}
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
parentLog, err := app.ListWorklog(parent.ID)
if err != nil {
t.Fatalf("ListWorklog(parent): %v", err)
}
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
}
if parentLog[0].NodeID != child.ID {
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
}
if parentLog[0].NodePath != "Parent Project > Documents" {
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
}
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
if err != nil {
t.Fatalf("ListActivityByNode(parent): %v", err)
}
var foundEvent *EventDTO
for i := range parentActivity {
if parentActivity[i].ID == eventID {
foundEvent = &parentActivity[i]
}
}
if foundEvent == nil {
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
}
if foundEvent.NodePath != "Parent Project > Documents" {
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
}
var physicalEvents int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
t.Fatalf("count physical event: %v", err)
}
if physicalEvents != 1 {
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
}
var copiedToParent int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
t.Fatalf("count copied parent event: %v", err)
}
if copiedToParent != 0 {
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
}
}
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
app, _ := setupTestApp(t)
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
t.Fatalf("DismissSuggestion: %v", err)
}
suggestions, err := app.GetSuggestions()
if err != nil {
t.Fatalf("GetSuggestions: %v", err)
}
for _, s := range suggestions {
if s.NodeID == n.ID {
t.Fatalf("dismissed suggestion still visible: %+v", s)
}
}
var eventCount int
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
t.Fatalf("count event: %v", err)
}
if eventCount != 1 {
t.Fatalf("activity event count = %d, want 1", eventCount)
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal json: %v", err)
}
return data
}
func TestApplyRemoteWorklogUpdate(t *testing.T) { func TestApplyRemoteWorklogUpdate(t *testing.T) {
app, _ := setupTestApp(t) app, _ := setupTestApp(t)

View File

@ -1186,6 +1186,15 @@
return [] return []
} }
async function deleteSuggestion(s) {
try {
await wailsCall('DismissSuggestion', s.nodeId, JSON.stringify(extractEventIds(s)))
await refreshAfterSuggestion()
} catch (e) {
error = String(e)
}
}
function writeDebugLog(msg) { function writeDebugLog(msg) {
try { wailsCall('WriteDebugLog', msg) } catch(e) {} try { wailsCall('WriteDebugLog', msg) } catch(e) {}
} }
@ -2468,14 +2477,18 @@
<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>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>
{t('common.delete')}
</button>
</div> </div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0} {#if s.events && s.events.length > 0}
<div class="suggestion-detail"> <div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div> <div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev} {#each s.events as ev}
<div class="suggestion-detail-event"> <div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span> <span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span> <span class="suggestion-event-type">{eventLabel(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={() => openActivityTarget(ev)}>{t('common.open')}</button> <button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
@ -2498,6 +2511,7 @@
<div class="worklog-entry-head"> <div class="worklog-entry-head">
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span> <span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span>
<span class="worklog-entry-summary">{e.summary}</span> <span class="worklog-entry-summary">{e.summary}</span>
{#if e.nodePath}<span class="worklog-entry-path">{e.nodePath}</span>{/if}
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</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.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if} {#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
@ -2562,6 +2576,7 @@
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}> <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>
{#if ev.nodePath}<span class="activity-event-path">{ev.nodePath}</span>{/if}
<span class="activity-event-type">{eventLabel(ev.eventType)}</span> <span class="activity-event-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if} {#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
<span class="activity-event-time">{formatTime(ev.createdAt)}</span> <span class="activity-event-time">{formatTime(ev.createdAt)}</span>
@ -2774,14 +2789,16 @@
<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" 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>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div> </div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0} {#if s.events && s.events.length > 0}
<div class="suggestion-detail"> <div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div> <div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev} {#each s.events as ev}
<div class="suggestion-detail-event"> <div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span> <span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span> <span class="suggestion-event-type">{eventLabel(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={() => openActivityTarget(ev)}>{t('common.open')}</button> <button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
@ -2933,14 +2950,16 @@
<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" 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>
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
</div> </div>
</div> </div>
{#if s._expanded && s.events && s.events.length > 0} {#if s.events && s.events.length > 0}
<div class="suggestion-detail"> <div class="suggestion-detail">
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div> <div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
{#each s.events as ev} {#each s.events as ev}
<div class="suggestion-detail-event"> <div class="suggestion-detail-event">
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span> <span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span> <span class="suggestion-event-type">{eventLabel(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={() => openActivityTarget(ev)}>{t('common.open')}</button> <button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
@ -3445,15 +3464,16 @@
.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-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
.suggestion-card.expanded { border-color: #3a3a5c; } .suggestion-card.expanded { border-color: #3a3a5c; }
.suggestion-detail { padding: 0 12px 10px; border-top: 1px solid #2a2a3c; } .suggestion-detail { width: 100%; padding: 8px 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-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-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-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-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-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; 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; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 0; border: 1px solid transparent; }
.suggestion-card:last-child { margin-bottom: 0; } .suggestion-card:last-child { margin-bottom: 0; }
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; } .suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; } .suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
@ -3739,6 +3759,7 @@
.activity-event:hover { background: #1a1a28; color: #e4e4ef; } .activity-event:hover { background: #1a1a28; color: #e4e4ef; }
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; } .activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-event-path { max-width: 240px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; } .activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; } .activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; } .activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
@ -3784,6 +3805,7 @@
.worklog-entry-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #e4e4ef; } .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-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-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worklog-entry-path { max-width: 240px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.worklog-entry-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; 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-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; } .worklog-entry-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }

View File

@ -9,13 +9,14 @@ const (
// SuggestionDetail is a lightweight event summary for suggestion display. // SuggestionDetail is a lightweight event summary for suggestion display.
type SuggestionDetail struct { type SuggestionDetail struct {
ID string `json:"id"` ID string `json:"id"`
EventType string `json:"eventType"` EventType string `json:"eventType"`
TargetType string `json:"targetType"` TargetType string `json:"targetType"`
TargetID string `json:"targetId"` TargetID string `json:"targetId"`
Title string `json:"title"` Title string `json:"title"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
NodeID string `json:"nodeId"` NodeID string `json:"nodeId"`
NodePath string `json:"nodePath,omitempty"`
} }
// Suggestion represents a suggested worklog entry derived from today's activity. // Suggestion represents a suggested worklog entry derived from today's activity.

View File

@ -0,0 +1,12 @@
package storage
// migration016 — dismissed worklog suggestion events.
const migration016 = `
CREATE TABLE IF NOT EXISTS worklog_dismissed_events (
event_id TEXT PRIMARY KEY REFERENCES activity_events(id),
node_id TEXT NOT NULL REFERENCES nodes(id),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_worklog_dismissed_node ON worklog_dismissed_events(node_id);
`

View File

@ -72,6 +72,7 @@ var migrationFiles = map[int]string{
13: migration013, 13: migration013,
14: migration014, 14: migration014,
15: migration015, 15: migration015,
16: migration016,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {