feat: aggregate journals across node subtrees
This commit is contained in:
parent
23f517dee3
commit
10b287de7b
|
|
@ -197,6 +197,7 @@ type WorklogDTO struct {
|
|||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Minutes int `json:"minutes"`
|
||||
Date string `json:"date,omitempty"`
|
||||
|
|
@ -217,6 +218,7 @@ type SearchResultDTO struct {
|
|||
type EventDTO struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
EventType string `json:"eventType"`
|
||||
TargetType string `json:"targetType"`
|
||||
TargetID string `json:"targetId"`
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
|||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -166,13 +166,13 @@ func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO,
|
|||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
||||
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]EventDTO, len(events))
|
||||
for i, e := range events {
|
||||
result[i] = toEventDTO(e)
|
||||
result[i] = a.eventDTOWithPath(e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
|
@ -185,3 +185,38 @@ func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
title string
|
||||
|
|
@ -90,6 +100,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
Title: e.Title,
|
||||
CreatedAt: e.CreatedAt,
|
||||
NodeID: e.NodeID,
|
||||
NodePath: a.nodes.Path(e.NodeID),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +126,38 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
|||
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).
|
||||
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
|||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := a.worklog.ListByNode(nodeID)
|
||||
rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
|
||||
if err != nil {
|
||||
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) {
|
||||
|
|
@ -264,3 +268,20 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
|||
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
|
|
@ -19,8 +19,8 @@
|
|||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-BQHjHDrT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DfazBFdN.css">
|
||||
<script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
app, _ := setupTestApp(t)
|
||||
|
||||
|
|
|
|||
|
|
@ -1186,6 +1186,15 @@
|
|||
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) {
|
||||
try { wailsCall('WriteDebugLog', msg) } catch(e) {}
|
||||
}
|
||||
|
|
@ -2468,14 +2477,18 @@
|
|||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
||||
{t('worklog.apply')}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</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-title">{t('suggest.detectedEvents')}</div>
|
||||
{#each s.events as ev}
|
||||
<div class="suggestion-detail-event">
|
||||
<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-title">{ev.title}</span>
|
||||
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
||||
|
|
@ -2498,6 +2511,7 @@
|
|||
<div class="worklog-entry-head">
|
||||
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</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>
|
||||
{#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}
|
||||
|
|
@ -2562,6 +2576,7 @@
|
|||
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
||||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="activity-event-title">{ev.title}</span>
|
||||
{#if ev.nodePath}<span class="activity-event-path">{ev.nodePath}</span>{/if}
|
||||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||
|
|
@ -2774,14 +2789,16 @@
|
|||
<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-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
|
||||
</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-title">{t('suggest.detectedEvents')}</div>
|
||||
{#each s.events as ev}
|
||||
<div class="suggestion-detail-event">
|
||||
<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-title">{ev.title}</span>
|
||||
<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>
|
||||
<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-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
|
||||
</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-title">{t('suggest.detectedEvents')}</div>
|
||||
{#each s.events as ev}
|
||||
<div class="suggestion-detail-event">
|
||||
<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-title">{ev.title}</span>
|
||||
<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-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 { 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-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-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 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-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; }
|
||||
|
|
@ -3739,6 +3759,7 @@
|
|||
.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-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-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; }
|
||||
|
|
@ -3784,6 +3805,7 @@
|
|||
.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-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-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; }
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ const (
|
|||
|
||||
// SuggestionDetail is a lightweight event summary for suggestion display.
|
||||
type SuggestionDetail struct {
|
||||
ID string `json:"id"`
|
||||
EventType string `json:"eventType"`
|
||||
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"`
|
||||
TargetID string `json:"targetId"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
NodeID string `json:"nodeId"`
|
||||
NodePath string `json:"nodePath,omitempty"`
|
||||
}
|
||||
|
||||
// Suggestion represents a suggested worklog entry derived from today's activity.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
`
|
||||
|
|
@ -72,6 +72,7 @@ var migrationFiles = map[int]string{
|
|||
13: migration013,
|
||||
14: migration014,
|
||||
15: migration015,
|
||||
16: migration016,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
|
|
|||
Loading…
Reference in New Issue