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'}
Дела {#if nodes.length > 0}({nodes.length}){/if}
{#each nodes as node}
@@ -1097,7 +1113,23 @@
{: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 @@
{#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 {