Compare commits

...

5 Commits

Author SHA1 Message Date
mirivlad a098cf721c add missing Wails bindings for ListActivityFeed, ListActivityByNode, CountActivityByNode 2026-06-01 22:00:58 +08:00
mirivlad 3672e3133b activity: global feed, per-case log, sidebar section, today UX
- 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
2026-06-01 02:53:56 +08:00
mirivlad 5a1c4c6d7f simplify ListTodayView: remove fallback table queries, pure activity_events + ListTodayNodes
TodayView now uses only activity_events (source of truth) + ListTodayNodes (ensure changed cases appear). Removed direct queries to nodes (notes) and files tables — those will come from activity_events going forward.
2026-06-01 02:45:55 +08:00
mirivlad 08c9d5dbea gitignore: fix verstak-gui pattern to root-only, add .verstak/ 2026-06-01 02:16:25 +08:00
mirivlad c74fa3ad43 today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar 2026-06-01 02:16:13 +08:00
32 changed files with 800 additions and 104 deletions

7
.gitignore vendored
View File

@ -22,8 +22,11 @@ go.work
frontend/dist/ frontend/dist/
frontend/node_modules/ frontend/node_modules/
frontend/bindings/ frontend/bindings/
verstak-gui /verstak-gui
verstak-cli /verstak-cli
# Vault data
.verstak/
# VS Code # VS Code
.vscode/ .vscode/

Binary file not shown.

View File

@ -6,11 +6,14 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions" "verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/notes" "verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
@ -28,6 +31,7 @@ type App struct {
nodes *nodes.Repository nodes *nodes.Repository
files *files.Service files *files.Service
notes *notes.Service notes *notes.Service
activity *activity.Service
actions *actions.Service actions *actions.Service
worklog *worklog.Service worklog *worklog.Service
search *search.Service search *search.Service
@ -115,6 +119,47 @@ type SearchResultDTO struct {
Type string `json:"type"` Type string `json:"type"`
} }
type EventDTO struct {
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 {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
}
type SummaryDTO struct {
ChangedCases int `json:"changedCases"`
Notes int `json:"notes"`
Files int `json:"files"`
Actions int `json:"actions"`
TimeEntries int `json:"timeEntries"`
}
type TodayGroupDTO struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodeKind string `json:"nodeKind"`
Section string `json:"section"`
LastActivityAt string `json:"lastActivityAt"`
Events []EventDTO `json:"events"`
}
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
Events []EventDTO `json:"events"`
}
// ============================================================ // ============================================================
// Sections // Sections
// ============================================================ // ============================================================
@ -123,6 +168,7 @@ func (a *App) ListSections() []SectionDTO {
return []SectionDTO{ return []SectionDTO{
{ID: "today", Label: "Сегодня"}, {ID: "today", Label: "Сегодня"},
{ID: "inbox", Label: "Неразобранное"}, {ID: "inbox", Label: "Неразобранное"},
{ID: "activity", Label: "Активность"},
{ID: "clients", Label: "Клиенты"}, {ID: "clients", Label: "Клиенты"},
{ID: "projects", Label: "Проекты"}, {ID: "projects", Label: "Проекты"},
{ID: "recipes", Label: "Рецепты"}, {ID: "recipes", Label: "Рецепты"},
@ -143,13 +189,173 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
return toNodeDTOs(list), nil return toNodeDTOs(list), nil
} }
// ListTodayView returns nodes created or updated today — a dynamic day view. // ListTodayView returns a dashboard of today's activity.
func (a *App) ListTodayView() ([]NodeDTO, error) { // For MVP this uses activity_events + root nodes changed today.
list, err := a.nodes.ListTodayNodes() // Future: full Activity/Event Log system will be the single source of truth.
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
// Collect events from activity_events, grouped by parent node.
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
aeByParent = nil
}
// Root nodes that were created/updated today.
todayNodes, _ := a.nodes.ListTodayNodes()
type rawEvent struct {
NodeID string
EventType string
TargetType string
TargetID string
TargetPath string
Title string
CreatedAt string
}
type caseInfo struct {
Node nodes.Node
Events []rawEvent
}
caseMap := make(map[string]*caseInfo)
ensureCase := func(caseID string) *caseInfo {
if ci, ok := caseMap[caseID]; ok {
return ci
}
ci := &caseInfo{Events: nil}
if n, err := a.nodes.GetActive(caseID); err == nil {
ci.Node = *n
}
caseMap[caseID] = ci
return ci
}
// Merge activity_events.
for pid, events := range aeByParent {
ci := ensureCase(pid)
for _, e := range events {
ci.Events = append(ci.Events, rawEvent{
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
CreatedAt: e.CreatedAt,
})
}
}
// Ensure all today's root nodes are present (even without events).
for _, n := range todayNodes {
_ = ensureCase(n.ID)
if ci := caseMap[n.ID]; ci.Node.ID == "" {
ci.Node = n
}
}
var groups []TodayGroupDTO
var flatEvents []EventDTO
summary := SummaryDTO{}
for _, ci := range caseMap {
if ci.Node.ID == "" {
continue
}
summary.ChangedCases++
dtoEvents := make([]EventDTO, 0, len(ci.Events))
for _, re := range ci.Events {
dtoEvents = append(dtoEvents, EventDTO{
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 {
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
summary.Notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
summary.Files++
}
}
last := ci.Node.UpdatedAt.Format(time.RFC3339)
for _, e := range dtoEvents {
if e.CreatedAt > last {
last = e.CreatedAt
}
}
groups = append(groups, TodayGroupDTO{
NodeID: ci.Node.ID,
NodeTitle: ci.Node.Title,
NodeKind: ci.Node.Type,
Section: ci.Node.Section,
LastActivityAt: last,
Events: dtoEvents,
})
flatEvents = append(flatEvents, dtoEvents...)
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].LastActivityAt > groups[j].LastActivityAt
})
sort.Slice(flatEvents, func(i, j int) bool {
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
})
return &TodayDashboardDTO{
Date: time.Now().Format("2006-01-02"),
Summary: summary,
Groups: groups,
Events: flatEvents,
}, 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 { if err != nil {
return nil, err return nil, err
} }
return toNodeDTOs(list), nil 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) { func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
@ -177,6 +383,7 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
dto := toNodeDTO(n) dto := toNodeDTO(n)
return &dto, nil return &dto, nil
} }
@ -210,6 +417,7 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
dto := toNodeDTO(node) dto := toNodeDTO(node)
return &dto, nil return &dto, nil
} }
@ -221,7 +429,18 @@ func (a *App) ReadNote(noteID string) (string, error) {
// SaveNote saves note content. // SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error { func (a *App) SaveNote(noteID, content string) error {
return a.notes.Save(noteID, content) if err := a.notes.Save(noteID, content); err != nil {
return err
}
// Record note_updated event.
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
}
return nil
} }
// ============================================================ // ============================================================
@ -290,6 +509,9 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
return toNodeDTOs(nodes), nil return toNodeDTOs(nodes), nil
} }
@ -298,10 +520,27 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
return toNodeDTOs(nodes), nil return toNodeDTOs(nodes), nil
} }
func (a *App) DeleteFileOrFolder(nodeID string) error { func (a *App) DeleteFileOrFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
}
return a.files.DeleteNodeAndChildren(nodeID) return a.files.DeleteNodeAndChildren(nodeID)
} }
@ -310,6 +549,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
dto := toNodeDTO(node) dto := toNodeDTO(node)
return &dto, nil return &dto, nil
} }
@ -319,12 +559,38 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Find parent for recording
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
dto := toNodeDTO(node) dto := toNodeDTO(node)
return &dto, nil return &dto, nil
} }
func (a *App) RenameNode(nodeID, newTitle string) error { func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle) n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
oldTitle := n.Title
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileRenamed
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderRenamed
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
return nil
} }
func (a *App) ValidateName(name string) error { func (a *App) ValidateName(name string) error {
@ -351,7 +617,15 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
break break
} }
} }
return a.nodes.Move(nodeID, newParentID, 0) if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
return err
}
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
return nil
} }
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) { func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {

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

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

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

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

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

@ -16,8 +16,8 @@
background: #13131f; background: #13131f;
} }
</style> </style>
<script type="module" crossorigin src="/assets/main-BY9JF_6I.js"></script> <script type="module" crossorigin src="/assets/main-DtITCkHU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css"> <link rel="stylesheet" crossorigin href="/assets/main-5x3eoU2l.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"verstak/internal/core/actions" "verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/files" "verstak/internal/core/files"
"verstak/internal/core/notes" "verstak/internal/core/notes"
"verstak/internal/core/nodes" "verstak/internal/core/nodes"
@ -46,19 +47,21 @@ func main() {
fileSvc := files.NewService(db, abs, nodeRepo) fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc) noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db) actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db) worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db) searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover() plugins.NewManager(abs).Discover()
app := &App{ app := &App{
db: db, db: db,
nodes: nodeRepo, nodes: nodeRepo,
files: fileSvc, files: fileSvc,
notes: noteSvc, notes: noteSvc,
actions: actionSvc, activity: activitySvc,
worklog: worklogSvc, actions: actionSvc,
search: searchSvc, worklog: worklogSvc,
vault: abs, search: searchSvc,
vault: abs,
} }
err = wails.Run(&options.App{ err = wails.Run(&options.App{

View File

@ -26,6 +26,12 @@
// ===== State ===== // ===== State =====
let sections = [] let sections = []
let nodes = [] let nodes = []
let todayDashboard = null
let activityFeed = []
let activityOffset = 0
let activityHasMore = true
let activityLoading = false
let caseActivity = []
let version = '' let version = ''
let error = '' let error = ''
let selectedSection = '' let selectedSection = ''
@ -99,6 +105,7 @@
sections = [ sections = [
{ id: 'today', label: 'Сегодня' }, { id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' }, { id: 'inbox', label: 'Неразобранное' },
{ id: 'activity', label: 'Активность' },
{ id: 'clients', label: 'Клиенты' }, { id: 'clients', label: 'Клиенты' },
{ id: 'projects', label: 'Проекты' }, { id: 'projects', label: 'Проекты' },
{ id: 'recipes', label: 'Рецепты' }, { id: 'recipes', label: 'Рецепты' },
@ -134,15 +141,26 @@
worklog = [] worklog = []
showCreateNode = false showCreateNode = false
error = '' error = ''
todayDashboard = null
activityFeed = []
activityOffset = 0
activityHasMore = true
nodes = []
try { try {
if (id === 'today') { if (id === 'today') {
nodes = await wailsCall('ListTodayView') || [] todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
} else if (id === 'activity') {
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
activityOffset = activityFeed.length
activityHasMore = activityFeed.length === 50
} else { } else {
nodes = await wailsCall('ListNodesBySection', id) || [] nodes = await wailsCall('ListNodesBySection', id) || []
} }
} catch (e) { } catch (e) {
error = String(e) error = String(e)
nodes = [] nodes = []
todayDashboard = { cases: [] }
activityFeed = []
} }
} }
@ -167,6 +185,7 @@
showCreateNode = false showCreateNode = false
showCreateNote = false showCreateNote = false
error = '' error = ''
caseActivity = []
await loadTabData(node.id) await loadTabData(node.id)
} }
@ -175,6 +194,7 @@
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {} try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {} try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
try { worklog = await wailsCall('ListWorklog', 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) { async function loadTree(nodeID) {
@ -755,10 +775,59 @@
// ===== Helpers ===== // ===== Helpers =====
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' } function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function eventLabel(type) {
const labels = {
'note_created': 'Заметка создана',
'note_updated': 'Заметка изменена',
'file_added': 'Файл добавлен',
'file_deleted': 'Файл удалён',
'file_renamed': 'Файл переименован',
'file_copied': 'Файл скопирован',
'file_moved': 'Файл перемещён',
'folder_added': 'Папка добавлена',
'folder_deleted': 'Папка удалена',
'folder_renamed': 'Папка переименована',
'node_created': 'Дело создано',
'node_updated': 'Дело изменено',
}
return labels[type] || type
}
function eventIcon(type) {
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
if (type === 'file_copied') return '⧉'
if (type === 'file_moved') return '→'
return '•'
}
function formatTime(str) {
if (!str) return ''
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
}
function formatDate(str) { function formatDate(str) {
if (!str) return '' if (!str) return ''
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str } try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
} }
function nodeKindLabel(kind) {
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
return labels[kind] || kind || 'Дело'
}
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
n %= 10
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
}
async function openNodeById(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
if (node) selectNode(node)
} catch (e) {
error = String(e)
}
}
</script> </script>
<div class="app"> <div class="app">
@ -778,7 +847,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if selectedSection} {#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="nav-group"> <div class="nav-group">
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div> <div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
{#each nodes as node} {#each nodes as node}
@ -1044,7 +1113,111 @@
</div> </div>
{:else if activeTab === 'activity'} {:else if activeTab === 'activity'}
<div class="empty-state"><p>Активность появится позже</p></div> <div class="activity-tab">
{#if caseActivity.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
{:else}
<div class="activity-events">
{#each caseActivity as ev}
<div class="activity-event">
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
<span class="activity-event-title">{ev.title}</span>
<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>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else if selectedSection === 'today' && todayDashboard}
<div class="today-dashboard">
<div class="today-header">
<h2>Сегодня</h2>
<span class="today-date">{todayDashboard.date}</span>
</div>
{#if todayDashboard.summary}
<div class="today-summary">
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
</div>
{/if}
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
{#each todayDashboard.groups as group}
<div class="today-case">
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-case-title">{group.nodeTitle}</span>
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
</div>
{#if group.events && group.events.length > 0}
<div class="today-events">
{#each group.events as ev}
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
<span class="today-event-title">{ev.title}</span>
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{:else}
<div class="today-events-empty">Изменён сегодня</div>
{/if}
</div>
{/each}
{#if todayDashboard.events && todayDashboard.events.length > 0}
<div class="today-timeline">
<h3>Лента за сегодня</h3>
{#each todayDashboard.events as ev}
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<span class="timeline-dot"></span>
<span class="timeline-title">{ev.title}</span>
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{/if}
{:else}
<div class="today-empty">
<p>Сегодня пока тихо</p>
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
</div>
{/if}
</div>
{:else if selectedSection === 'activity'}
<div class="activity-feed">
<div class="activity-feed-header">
<h2>Активность</h2>
</div>
{#if activityFeed.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
{:else}
<div class="activity-feed-events">
{#each activityFeed as ev}
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
<div class="activity-feed-body">
<span class="activity-feed-title">{ev.title}</span>
<div class="activity-feed-meta">
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if} {/if}
</div> </div>
@ -1059,7 +1232,7 @@
</div> </div>
{/if} {/if}
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox'} {#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div> <div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
{/if} {/if}
@ -1075,7 +1248,7 @@
<div class="form-group"> <div class="form-group">
<label>Раздел</label> <label>Раздел</label>
<select bind:value={newNodeSection}> <select bind:value={newNodeSection}>
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s} {#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
<option value={s.id}>{s.label}</option> <option value={s.id}>{s.label}</option>
{/each} {/each}
</select> </select>
@ -1286,4 +1459,64 @@
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; } .summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; } .rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
/* Today Dashboard */
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.today-header h2 { font-size: 24px; }
.today-date { font-size: 13px; color: #666; }
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
.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; 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; }
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
.today-empty { padding: 48px 24px; text-align: center; }
.today-empty p { color: #666; font-size: 14px; margin: 0; }
.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; 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; }
</style> </style>

View File

@ -109,3 +109,19 @@ export function OpenFolder(arg1) {
export function VerstakVersion() { export function VerstakVersion() {
return window['go']['main']['App']['VerstakVersion'](); return window['go']['main']['App']['VerstakVersion']();
} }
export function ListActivityFeed(arg1, arg2) {
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
}
export function ListActivityByNode(arg1, arg2, arg3) {
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
}
export function CountActivityByNode(arg1) {
return window['go']['main']['App']['CountActivityByNode'](arg1);
}
export function CreateEmptyFile(arg1, arg2) {
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
}

View File

@ -0,0 +1,182 @@
package activity
import (
"database/sql"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// 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"
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"`
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.
type Service struct {
db *storage.DB
}
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Record inserts a new activity event.
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`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 as RFC3339 strings.
func TodayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
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,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)
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()
}
// ListTodayEventsByParent returns today's events grouped by their NodeID.
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
events, err := s.ListTodayEvents()
if err != nil {
return nil, err
}
grouped := make(map[string][]Event, 8)
for _, e := range events {
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
}

View File

@ -84,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
}
return n, nil return n, nil
} }
// touch updates a node's updated_at without changing other fields.
func (r *Repository) touch(id string) error {
t := now()
_, err := r.db.Exec(
`UPDATE nodes SET updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`, t, id)
return err
}
func (r *Repository) insertNode(n *Node) error { func (r *Repository) insertNode(n *Node) error {
var parent interface{} var parent interface{}
if n.ParentID != nil { if n.ParentID != nil {
@ -180,24 +193,26 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
} }
// todayBoundaries returns RFC3339 start and end strings for the current day // todayBoundaries returns RFC3339 start and end strings for the current day
// in the local timezone (the server's timezone, which should match the user's). // in UTC, so string comparison against UTC-stored DB timestamps is correct.
// TODO: accept a user timezone offset when multi-user support is added.
func todayBoundaries() (string, string) { func todayBoundaries() (string, string) {
now := time.Now() now := time.Now()
y, m, d := now.Date() y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour) end := start.Add(24 * time.Hour)
return start.Format(time.RFC3339), end.Format(time.RFC3339) return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
} }
// ListTodayNodes returns active root-level nodes created or updated today. // ListTodayNodes returns active root-level nodes created or updated today.
// This is a dynamic view, not a section — it shows the day's activity. // This is a dynamic view, not a section — it shows the day's activity.
// Child nodes (notes, files, folders) are not listed directly; instead,
// their parent is bumped via touch() on creation.
func (r *Repository) ListTodayNodes() ([]Node, error) { func (r *Repository) ListTodayNodes() ([]Node, error) {
start, end := todayBoundaries() start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order, q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id created_at,updated_at,deleted_at,revision,device_id
FROM nodes FROM nodes
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND parent_id IS NULL
AND ( AND (
(created_at >= ? AND created_at < ?) (created_at >= ? AND created_at < ?)
OR OR

View File

@ -47,8 +47,9 @@ var validSections = map[string]struct{}{
// serviceSections are sidebar entries that are not stored as node sections. // serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{ var serviceSections = map[string]struct{}{
"today": {}, "today": {},
"inbox": {}, "inbox": {},
"activity": {},
} }
// IsValidType checks whether a type string is recognized. // IsValidType checks whether a type string is recognized.

View File

@ -0,0 +1,17 @@
package storage
// migration008 — activity_events table for the Сегодня (Today) dashboard.
const migration008 = `
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
parent_id TEXT,
event_type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
`

View File

@ -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;
`

View File

@ -64,7 +64,8 @@ var migrationFiles = map[int]string{
5: migration005, 5: migration005,
6: migration006, 6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild() // 7: migration007 (FTS5) — created lazily by search.Rebuild()
// 8: migration008, etc. 8: migration008,
9: migration009,
} }
func (db *DB) runInitialSchema() error { func (db *DB) runInitialSchema() error {