From 3672e3133b94d0868880dd3fe13211b916a5230b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 1 Jun 2026 02:53:56 +0800 Subject: [PATCH] activity: global feed, per-case log, sidebar section, today UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 009: target_type, target_id, target_path columns - new Event fields: TargetType, TargetID, TargetPath - ListActivityFeed (paginated global), ListActivityByNode (per-case) - all Record() callsites pass target info - frontend: Активность sidebar section with chronological feed - per-case Активность tab with real data (was placeholder) - today events: clickable, target-type badges, event counts --- cmd/verstak-gui/app.go | 118 +++++++++++---- frontend/src/App.svelte | 107 ++++++++++++-- internal/core/activity/activity.go | 150 ++++++++++++++------ internal/core/nodes/types.go | 5 +- internal/core/storage/migrations_009.sql.go | 8 ++ internal/core/storage/storage.go | 1 + 6 files changed, 305 insertions(+), 84 deletions(-) create mode 100644 internal/core/storage/migrations_009.sql.go diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index 2917f96..5a2b804 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -120,13 +120,15 @@ type SearchResultDTO struct { } type EventDTO struct { - ID string `json:"id"` - NodeID string `json:"nodeId"` - ParentID string `json:"parentId"` - EventType string `json:"eventType"` - Title string `json:"title"` - Metadata string `json:"metadata"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + NodeID string `json:"nodeId"` + EventType string `json:"eventType"` + TargetType string `json:"targetType"` + TargetID string `json:"targetId"` + TargetPath string `json:"targetPath"` + Title string `json:"title"` + DetailsJSON string `json:"detailsJson"` + CreatedAt string `json:"createdAt"` } type CaseActivityDTO struct { @@ -166,6 +168,7 @@ func (a *App) ListSections() []SectionDTO { return []SectionDTO{ {ID: "today", Label: "Сегодня"}, {ID: "inbox", Label: "Неразобранное"}, + {ID: "activity", Label: "Активность"}, {ID: "clients", Label: "Клиенты"}, {ID: "projects", Label: "Проекты"}, {ID: "recipes", Label: "Рецепты"}, @@ -200,10 +203,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) { todayNodes, _ := a.nodes.ListTodayNodes() type rawEvent struct { - NodeID string - EventType string - Title string - CreatedAt string + NodeID string + EventType string + TargetType string + TargetID string + TargetPath string + Title string + CreatedAt string } type caseInfo struct { Node nodes.Node @@ -228,10 +234,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) { ci := ensureCase(pid) for _, e := range events { ci.Events = append(ci.Events, rawEvent{ - NodeID: e.NodeID, - EventType: e.EventType, - Title: e.Title, - CreatedAt: e.CreatedAt, + NodeID: e.NodeID, + EventType: e.EventType, + TargetType: e.TargetType, + TargetID: e.TargetID, + TargetPath: e.TargetPath, + Title: e.Title, + CreatedAt: e.CreatedAt, }) } } @@ -257,12 +266,13 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) { dtoEvents := make([]EventDTO, 0, len(ci.Events)) for _, re := range ci.Events { dtoEvents = append(dtoEvents, EventDTO{ - ID: ci.Node.ID + "/" + re.NodeID, - NodeID: re.NodeID, - ParentID: ci.Node.ID, - EventType: re.EventType, - Title: re.Title, - Metadata: "{}", + ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt, + NodeID: re.NodeID, + EventType: re.EventType, + TargetType: re.TargetType, + TargetID: re.TargetID, + TargetPath: re.TargetPath, + Title: re.Title, CreatedAt: re.CreatedAt, }) switch re.EventType { @@ -306,6 +316,48 @@ func (a *App) ListTodayView() (*TodayDashboardDTO, error) { }, nil } +func toEventDTO(e activity.Event) EventDTO { + return EventDTO{ + ID: e.ID, + NodeID: e.NodeID, + EventType: e.EventType, + TargetType: e.TargetType, + TargetID: e.TargetID, + TargetPath: e.TargetPath, + Title: e.Title, + DetailsJSON: e.DetailsJSON, + CreatedAt: e.CreatedAt, + } +} + +func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) { + events, err := a.activity.ListRecent(limit, offset) + if err != nil { + return nil, err + } + result := make([]EventDTO, len(events)) + for i, e := range events { + result[i] = toEventDTO(e) + } + return result, nil +} + +func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) { + events, err := a.activity.ListByNode(nodeID, limit, offset) + if err != nil { + return nil, err + } + result := make([]EventDTO, len(events)) + for i, e := range events { + result[i] = toEventDTO(e) + } + return result, nil +} + +func (a *App) CountActivityByNode(nodeID string) (int, error) { + return a.activity.CountByNode(nodeID) +} + func (a *App) ListChildren(parentID string) ([]NodeDTO, error) { list, err := a.nodes.ListChildren(parentID, false) if err != nil { @@ -331,7 +383,7 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e if err != nil { return nil, err } - _ = a.activity.Record(n.ID, parentID, activity.TypeNodeCreated, title, "") + _ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "") dto := toNodeDTO(n) return &dto, nil } @@ -365,7 +417,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) { if err != nil { return nil, err } - _ = a.activity.Record(node.ID, parentID, activity.TypeNoteCreated, title, "") + _ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "") dto := toNodeDTO(node) return &dto, nil } @@ -386,7 +438,7 @@ func (a *App) SaveNote(noteID, content string) error { if n.ParentID != nil { pid = *n.ParentID } - _ = a.activity.Record(noteID, pid, activity.TypeNoteUpdated, n.Title, "") + _ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "") } return nil } @@ -458,7 +510,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) { return nil, err } for _, n := range nodes { - _ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) + _ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) } return toNodeDTOs(nodes), nil } @@ -469,7 +521,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) { return nil, err } for _, n := range nodes { - _ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) + _ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) } return toNodeDTOs(nodes), nil } @@ -482,10 +534,12 @@ func (a *App) DeleteFileOrFolder(nodeID string) error { pid = *n.ParentID } evType := activity.TypeFileDeleted + targetType := activity.TargetFile if n.Type == nodes.TypeFolder { evType = activity.TypeFolderDeleted + targetType = activity.TargetFolder } - _ = a.activity.Record(nodeID, pid, evType, n.Title, "") + _ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "") } return a.files.DeleteNodeAndChildren(nodeID) } @@ -495,7 +549,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) { if err != nil { return nil, err } - _ = a.activity.Record(node.ID, parentID, activity.TypeFileAdded, filename, "") + _ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "") dto := toNodeDTO(node) return &dto, nil } @@ -511,7 +565,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) { if err2 == nil && n.ParentID != nil { pid = *n.ParentID } - _ = a.activity.Record(node.ID, pid, activity.TypeFileCopied, node.Title, "") + _ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "") dto := toNodeDTO(node) return &dto, nil } @@ -530,10 +584,12 @@ func (a *App) RenameNode(nodeID, newTitle string) error { pid = *n.ParentID } evType := activity.TypeFileRenamed + targetType := activity.TargetFile if n.Type == nodes.TypeFolder { evType = activity.TypeFolderRenamed + targetType = activity.TargetFolder } - _ = a.activity.Record(nodeID, pid, evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) + _ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) return nil } @@ -568,7 +624,7 @@ func (a *App) MoveNode(nodeID, newParentID string) error { if node.ParentID != nil { pid = *node.ParentID } - _ = a.activity.Record(nodeID, pid, activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`) + _ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`) return nil } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 070d800..7fba784 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -27,6 +27,11 @@ let sections = [] let nodes = [] let todayDashboard = null + let activityFeed = [] + let activityOffset = 0 + let activityHasMore = true + let activityLoading = false + let caseActivity = [] let version = '' let error = '' let selectedSection = '' @@ -100,6 +105,7 @@ sections = [ { id: 'today', label: 'Сегодня' }, { id: 'inbox', label: 'Неразобранное' }, + { id: 'activity', label: 'Активность' }, { id: 'clients', label: 'Клиенты' }, { id: 'projects', label: 'Проекты' }, { id: 'recipes', label: 'Рецепты' }, @@ -136,10 +142,17 @@ showCreateNode = false error = '' todayDashboard = null + activityFeed = [] + activityOffset = 0 + activityHasMore = true nodes = [] try { if (id === 'today') { todayDashboard = await wailsCall('ListTodayView') || { cases: [] } + } else if (id === 'activity') { + activityFeed = await wailsCall('ListActivityFeed', 50, 0) || [] + activityOffset = activityFeed.length + activityHasMore = activityFeed.length === 50 } else { nodes = await wailsCall('ListNodesBySection', id) || [] } @@ -147,6 +160,7 @@ error = String(e) nodes = [] todayDashboard = { cases: [] } + activityFeed = [] } } @@ -171,6 +185,7 @@ showCreateNode = false showCreateNote = false error = '' + caseActivity = [] await loadTabData(node.id) } @@ -179,6 +194,7 @@ try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {} try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {} try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {} + try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {} } async function loadTree(nodeID) { @@ -831,7 +847,7 @@ {/each} - {#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox'} + {#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'} {:else if activeTab === 'activity'} -

Активность появится позже

+
+ {#if caseActivity.length === 0} +

Активность пока не зафиксирована

+ {:else} +
+ {#each caseActivity as ev} +
+ {eventIcon(ev.eventType)} + {ev.title} + {eventLabel(ev.eventType)} + {#if ev.targetType}{ev.targetType}{/if} + {formatTime(ev.createdAt)} +
+ {/each} +
+ {/if} +
{/if} @@ -1121,21 +1153,23 @@
openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}> {group.nodeTitle} {nodeKindLabel(group.nodeKind)} + {#if group.events}{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}{/if} {formatTime(group.lastActivityAt)}
{#if group.events && group.events.length > 0}
{#each group.events as ev} -
+
openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}> {eventIcon(ev.eventType)} {ev.title} {eventLabel(ev.eventType)} + {#if ev.targetType}{ev.targetType}{/if} {formatTime(ev.createdAt)}
{/each}
{:else} -
Изменён сегодня, подробная история пока недоступна
+
Изменён сегодня
{/if}
{/each} @@ -1144,7 +1178,7 @@

Лента за сегодня

{#each todayDashboard.events as ev} -
+
openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}> {ev.title} {eventLabel(ev.eventType)} @@ -1161,6 +1195,32 @@ {/if}
+ {:else if selectedSection === 'activity'} +
+
+

Активность

+
+ {#if activityFeed.length === 0} +

Активность пока не зафиксирована

+ {:else} +
+ {#each activityFeed as ev} +
openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}> + {eventIcon(ev.eventType)} +
+ {ev.title} +
+ {eventLabel(ev.eventType)} + {#if ev.targetType}{ev.targetType}{/if} + {formatDate(ev.createdAt)} {formatTime(ev.createdAt)} +
+
+
+ {/each} +
+ {/if} +
+ {:else}

Верстак

@@ -1172,7 +1232,7 @@
{/if} - {#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'} + {#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
+
{/if} @@ -1188,7 +1248,7 @@
@@ -1412,9 +1472,11 @@ .today-case-header:hover { background: #1e1e30; } .today-case-title { font-weight: 500; } .today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; } +.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; } .today-case-time { font-size: 11px; color: #555; margin-left: auto; } .today-events { padding: 8px 16px; } -.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; } +.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; } +.today-event:hover { color: #e4e4ef; } .today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; } .today-event-title { flex: 1; } .today-event-type { font-size: 11px; color: #666; } @@ -1425,9 +1487,36 @@ .today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; } .today-timeline { margin-top: 24px; } .today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; } -.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; } +.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; } +.timeline-event:hover { color: #e4e4ef; } .timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; } .timeline-title { flex: 1; } .timeline-type { font-size: 11px; color: #666; } .timeline-time { font-size: 11px; color: #555; } + +/* Activity tab (per-case) */ +.activity-tab { padding: 24px; } +.activity-events { display: flex; flex-direction: column; gap: 2px; } +.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; } +.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-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; } + +/* Activity feed (global section) */ +.activity-feed { padding: 24px; overflow-y: auto; flex: 1; } +.activity-feed-header { margin-bottom: 20px; } +.activity-feed-header h2 { font-size: 24px; } +.activity-feed-events { display: flex; flex-direction: column; gap: 2px; } +.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; } +.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; } +.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; } +.activity-feed-body { flex: 1; min-width: 0; } +.activity-feed-title { font-size: 14px; color: #e4e4ef; } +.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; } +.activity-feed-type { font-size: 11px; color: #666; } +.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; } +.activity-feed-time { font-size: 11px; color: #555; } diff --git a/internal/core/activity/activity.go b/internal/core/activity/activity.go index 6e4f35b..adda1de 100644 --- a/internal/core/activity/activity.go +++ b/internal/core/activity/activity.go @@ -1,6 +1,7 @@ package activity import ( + "database/sql" "time" "verstak/internal/core/storage" @@ -9,29 +10,44 @@ import ( // Event types. const ( - TypeNoteCreated = "note_created" - TypeNoteUpdated = "note_updated" - TypeFileAdded = "file_added" - TypeFileDeleted = "file_deleted" - TypeFileRenamed = "file_renamed" - TypeFileCopied = "file_copied" - TypeFileMoved = "file_moved" - TypeFolderAdded = "folder_added" - TypeFolderDeleted = "folder_deleted" - TypeFolderRenamed = "folder_renamed" - TypeNodeCreated = "node_created" - TypeNodeUpdated = "node_updated" + TypeNoteCreated = "note_created" + TypeNoteUpdated = "note_updated" + TypeFileAdded = "file_added" + TypeFileDeleted = "file_deleted" + TypeFileRenamed = "file_renamed" + TypeFileCopied = "file_copied" + TypeFileMoved = "file_moved" + TypeFolderAdded = "folder_added" + TypeFolderDeleted = "folder_deleted" + TypeFolderRenamed = "folder_renamed" + TypeNodeCreated = "node_created" + TypeNodeUpdated = "node_updated" + TypeActionCreated = "action_created" + TypeActionDone = "action_done" + TypeWorklogAdded = "worklog_added" +) + +// Target types. +const ( + TargetNote = "note" + TargetFile = "file" + TargetFolder = "folder" + TargetAction = "action" + TargetNode = "node" + TargetWorklog = "worklog" ) // Event represents an activity event. type Event struct { - ID string `json:"id"` - NodeID string `json:"node_id"` - ParentID string `json:"parent_id,omitempty"` - EventType string `json:"event_type"` - Title string `json:"title"` - Metadata string `json:"metadata"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + NodeID string `json:"node_id"` + EventType string `json:"event_type"` + TargetType string `json:"target_type,omitempty"` + TargetID string `json:"target_id,omitempty"` + TargetPath string `json:"target_path,omitempty"` + Title string `json:"title"` + DetailsJSON string `json:"details_json,omitempty"` + CreatedAt string `json:"created_at"` } // Service records and queries activity events. @@ -44,27 +60,16 @@ func NewService(db *storage.DB) *Service { } // Record inserts a new activity event. -func (s *Service) Record(nodeID, parentID, eventType, title, metadata string) error { +func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error { id := util.UUID7() now := time.Now().UTC().Format(time.RFC3339) - if metadata == "" { - metadata = "{}" - } - if parentID != "" { - _, err := s.db.Exec( - `INSERT INTO activity_events(id,node_id,parent_id,event_type,title,metadata,created_at) - VALUES(?,?,?,?,?,?,?)`, - id, nodeID, parentID, eventType, title, metadata, now) - return err - } _, err := s.db.Exec( - `INSERT INTO activity_events(id,node_id,event_type,title,metadata,created_at) - VALUES(?,?,?,?,?,?)`, - id, nodeID, eventType, title, metadata, now) + `INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at) + VALUES(?,?,?,?,?,?,?,?,?)`, + id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now) return err } -// todayBoundaries returns the current local day in UTC. // TodayBoundaries returns the current local day in UTC as RFC3339 strings. func TodayBoundaries() (string, string) { now := time.Now() @@ -74,11 +79,21 @@ func TodayBoundaries() (string, string) { return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339) } +// scanEvent scans a single event row. +func scanEvent(rows *sql.Rows) (Event, error) { + var e Event + err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, + &e.TargetType, &e.TargetID, &e.TargetPath, + &e.Title, &e.DetailsJSON, &e.CreatedAt) + return e, err +} + // ListTodayEvents returns all activity events from today. func (s *Service) ListTodayEvents() ([]Event, error) { start, end := TodayBoundaries() rows, err := s.db.Query( - `SELECT id,node_id,COALESCE(parent_id,''),event_type,title,metadata,created_at + `SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''), + title,COALESCE(metadata,'{}'),created_at FROM activity_events WHERE created_at >= ? AND created_at < ? ORDER BY created_at DESC`, start, end) @@ -88,8 +103,8 @@ func (s *Service) ListTodayEvents() ([]Event, error) { defer rows.Close() var events []Event for rows.Next() { - var e Event - if err := rows.Scan(&e.ID, &e.NodeID, &e.ParentID, &e.EventType, &e.Title, &e.Metadata, &e.CreatedAt); err != nil { + e, err := scanEvent(rows) + if err != nil { return nil, err } events = append(events, e) @@ -97,7 +112,7 @@ func (s *Service) ListTodayEvents() ([]Event, error) { return events, rows.Err() } -// ListTodayEventsByParent returns activity events grouped by parent node. +// ListTodayEventsByParent returns today's events grouped by their NodeID. func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) { events, err := s.ListTodayEvents() if err != nil { @@ -105,12 +120,63 @@ func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) { } grouped := make(map[string][]Event, 8) for _, e := range events { - pid := e.ParentID - if pid == "" { - pid = e.NodeID - } + pid := e.NodeID grouped[pid] = append(grouped[pid], e) } return grouped, nil } +// ListRecent returns a paginated global activity feed. +func (s *Service) ListRecent(limit, offset int) ([]Event, error) { + rows, err := s.db.Query( + `SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''), + title,COALESCE(metadata,'{}'),created_at + FROM activity_events + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var events []Event + for rows.Next() { + e, err := scanEvent(rows) + if err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + +// ListByNode returns paginated events for a specific node. +func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) { + rows, err := s.db.Query( + `SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''), + title,COALESCE(metadata,'{}'),created_at + FROM activity_events + WHERE node_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, nodeID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var events []Event + for rows.Next() { + e, err := scanEvent(rows) + if err != nil { + return nil, err + } + events = append(events, e) + } + return events, rows.Err() +} + +// CountByNode returns the total number of events for a node. +func (s *Service) CountByNode(nodeID string) (int, error) { + var count int + err := s.db.QueryRow( + `SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count) + return count, err +} diff --git a/internal/core/nodes/types.go b/internal/core/nodes/types.go index 581e2f6..2bd6ddb 100644 --- a/internal/core/nodes/types.go +++ b/internal/core/nodes/types.go @@ -47,8 +47,9 @@ var validSections = map[string]struct{}{ // serviceSections are sidebar entries that are not stored as node sections. var serviceSections = map[string]struct{}{ - "today": {}, - "inbox": {}, + "today": {}, + "inbox": {}, + "activity": {}, } // IsValidType checks whether a type string is recognized. diff --git a/internal/core/storage/migrations_009.sql.go b/internal/core/storage/migrations_009.sql.go new file mode 100644 index 0000000..2734e82 --- /dev/null +++ b/internal/core/storage/migrations_009.sql.go @@ -0,0 +1,8 @@ +package storage + +// migration009 — add target_type, target_id, target_path to activity_events. +const migration009 = ` +ALTER TABLE activity_events ADD COLUMN target_type TEXT; +ALTER TABLE activity_events ADD COLUMN target_id TEXT; +ALTER TABLE activity_events ADD COLUMN target_path TEXT; +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index d095504..78ad036 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -65,6 +65,7 @@ var migrationFiles = map[int]string{ 6: migration006, // 7: migration007 (FTS5) — created lazily by search.Rebuild() 8: migration008, + 9: migration009, } func (db *DB) runInitialSchema() error {