today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar

This commit is contained in:
mirivlad 2026-06-01 02:16:13 +08:00
parent 69891e395c
commit c74fa3ad43
27 changed files with 665 additions and 98 deletions

View File

@ -6,11 +6,14 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
@ -28,6 +31,7 @@ type App struct {
nodes *nodes.Repository
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
@ -115,6 +119,45 @@ type SearchResultDTO struct {
Type string `json:"type"`
}
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"`
}
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
// ============================================================
@ -143,13 +186,235 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
return toNodeDTOs(list), nil
}
// ListTodayView returns nodes created or updated today — a dynamic day view.
func (a *App) ListTodayView() ([]NodeDTO, error) {
list, err := a.nodes.ListTodayNodes()
// ListTodayView returns a dashboard of today's activity.
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
start, end := activity.TodayBoundaries()
// 1. Collect events from activity_events table.
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
return nil, err
aeByParent = nil
}
return toNodeDTOs(list), nil
// 2. Query notes created/updated today directly from nodes.
type noteRow struct {
ID string
ParentID string
Title string
CreatedAt string
}
var todayNotes []noteRow
if r, err := a.db.Query(`SELECT n.id, COALESCE(n.parent_id,''), n.title, n.created_at
FROM nodes n
WHERE n.deleted_at IS NULL AND n.type='note'
AND ((n.created_at >= ?1 AND n.created_at < ?2) OR (n.updated_at >= ?1 AND n.updated_at < ?2))`,
start, end); err == nil {
for r.Next() {
var nr noteRow
if err := r.Scan(&nr.ID, &nr.ParentID, &nr.Title, &nr.CreatedAt); err == nil {
todayNotes = append(todayNotes, nr)
}
}
r.Close()
}
// 3. Query files created today from files table.
type fileRow struct {
ID string
NodeID string
Filename string
ParentID string
CreatedAt string
}
var todayFiles []fileRow
if r, err := a.db.Query(`SELECT f.id, f.node_id, f.filename, COALESCE(n.parent_id,''), f.created_at
FROM files f
JOIN nodes n ON f.node_id = n.id
WHERE n.deleted_at IS NULL
AND (f.created_at >= ?1 AND f.created_at < ?2)`, start, end); err == nil {
for r.Next() {
var fr fileRow
if err := r.Scan(&fr.ID, &fr.NodeID, &fr.Filename, &fr.ParentID, &fr.CreatedAt); err == nil {
todayFiles = append(todayFiles, fr)
}
}
r.Close()
}
// Also include files updated today (but not created today).
if r, err := a.db.Query(`SELECT f.id, f.node_id, f.filename, COALESCE(n.parent_id,''), f.updated_at
FROM files f
JOIN nodes n ON f.node_id = n.id
WHERE n.deleted_at IS NULL
AND f.updated_at >= ?1 AND f.updated_at < ?2
AND f.created_at < ?1`, start, end); err == nil {
for r.Next() {
var fr fileRow
if err := r.Scan(&fr.ID, &fr.NodeID, &fr.Filename, &fr.ParentID, &fr.CreatedAt); err == nil {
todayFiles = append(todayFiles, fr)
}
}
r.Close()
}
// 4. Get root nodes that were created/updated today.
todayNodes, _ := a.nodes.ListTodayNodes()
// Build caseID → events map from all sources.
type rawEvent struct {
NodeID string
ParentID string
EventType string
Title string
CreatedAt string
}
type caseInfo struct {
Node nodes.Node
Events []rawEvent
}
caseMap := make(map[string]*caseInfo)
// Helper: ensure case entry exists.
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,
ParentID: pid,
EventType: e.EventType,
Title: e.Title,
CreatedAt: e.CreatedAt,
})
}
}
// Merge notes from direct query (avoid duplicates with ae).
noteSeen := make(map[string]bool)
for _, nr := range todayNotes {
if noteSeen[nr.ID] {
continue
}
noteSeen[nr.ID] = true
caseID := nr.ParentID
ci := ensureCase(caseID)
ci.Events = append(ci.Events, rawEvent{
NodeID: nr.ID,
ParentID: caseID,
EventType: activity.TypeNoteCreated,
Title: nr.Title,
CreatedAt: nr.CreatedAt,
})
}
// Merge files.
fileSeen := make(map[string]bool)
for _, fr := range todayFiles {
if fileSeen[fr.ID] {
continue
}
fileSeen[fr.ID] = true
caseID := fr.ParentID
ci := ensureCase(caseID)
ci.Events = append(ci.Events, rawEvent{
NodeID: fr.NodeID,
ParentID: caseID,
EventType: activity.TypeFileAdded,
Title: fr.Filename,
CreatedAt: fr.CreatedAt,
})
}
// Merge today's root nodes (even without events).
for _, n := range todayNodes {
_ = ensureCase(n.ID)
if ci := caseMap[n.ID]; ci.Node.ID == "" {
ci.Node = n
}
}
// Build final groups and flat timeline.
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 {
et := re.EventType
dtoEvents = append(dtoEvents, EventDTO{
ID: ci.Node.ID + "/" + re.NodeID,
NodeID: re.NodeID,
ParentID: re.ParentID,
EventType: et,
Title: re.Title,
Metadata: "{}",
CreatedAt: re.CreatedAt,
})
switch et {
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)
if len(dtoEvents) > 0 {
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 groups by lastActivityAt desc.
sort.Slice(groups, func(i, j int) bool {
return groups[i].LastActivityAt > groups[j].LastActivityAt
})
// Sort flat events by createdAt desc.
sort.Slice(flatEvents, func(i, j int) bool {
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
})
dateStr := time.Now().Format("2006-01-02")
return &TodayDashboardDTO{
Date: dateStr,
Summary: summary,
Groups: groups,
Events: flatEvents,
}, nil
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
@ -177,6 +442,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, "")
dto := toNodeDTO(n)
return &dto, nil
}
@ -210,6 +476,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, "")
dto := toNodeDTO(node)
return &dto, nil
}
@ -221,7 +488,18 @@ func (a *App) ReadNote(noteID string) (string, error) {
// SaveNote saves note content.
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(noteID, pid, activity.TypeNoteUpdated, n.Title, "")
}
return nil
}
// ============================================================
@ -290,6 +568,9 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
return toNodeDTOs(nodes), nil
}
@ -298,10 +579,25 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
}
return toNodeDTOs(nodes), nil
}
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
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
}
_ = a.activity.Record(nodeID, pid, evType, n.Title, "")
}
return a.files.DeleteNodeAndChildren(nodeID)
}
@ -310,6 +606,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, "")
dto := toNodeDTO(node)
return &dto, nil
}
@ -319,12 +616,36 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err != nil {
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(node.ID, pid, activity.TypeFileCopied, node.Title, "")
dto := toNodeDTO(node)
return &dto, nil
}
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
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderRenamed
}
_ = a.activity.Record(nodeID, pid, evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
return nil
}
func (a *App) ValidateName(name string) error {
@ -351,7 +672,15 @@ func (a *App) MoveNode(nodeID, newParentID string) error {
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(nodeID, pid, activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
return nil
}
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;
}
</style>
<script type="module" crossorigin src="/assets/main-BY9JF_6I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bo58X7Pc.css">
<script type="module" crossorigin src="/assets/main-Dt-7hdPr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-D2H3H_wv.css">
</head>
<body>
<div id="app"></div>

View File

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

View File

@ -26,6 +26,7 @@
// ===== State =====
let sections = []
let nodes = []
let todayDashboard = null
let version = ''
let error = ''
let selectedSection = ''
@ -134,15 +135,18 @@
worklog = []
showCreateNode = false
error = ''
todayDashboard = null
nodes = []
try {
if (id === 'today') {
nodes = await wailsCall('ListTodayView') || []
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
} else {
nodes = await wailsCall('ListNodesBySection', id) || []
}
} catch (e) {
error = String(e)
nodes = []
todayDashboard = { cases: [] }
}
}
@ -755,10 +759,59 @@
// ===== Helpers =====
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) {
if (!str) return ''
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>
<div class="app">
@ -778,7 +831,7 @@
</button>
{/each}
</div>
{#if selectedSection}
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox'}
<div class="nav-group">
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
{#each nodes as node}
@ -1048,6 +1101,66 @@
{/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>
<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">
<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>
<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">
<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}
<div class="welcome">
<h2>Верстак</h2>
@ -1286,4 +1399,35 @@
.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; }
/* 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-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-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; }
.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; }
</style>

View File

@ -0,0 +1,116 @@
package activity
import (
"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"
)
// 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"`
}
// 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, parentID, eventType, title, metadata 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)
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()
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)
}
// 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
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() {
var e Event
if err := rows.Scan(&e.ID, &e.NodeID, &e.ParentID, &e.EventType, &e.Title, &e.Metadata, &e.CreatedAt); err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListTodayEventsByParent returns activity events grouped by parent node.
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.ParentID
if pid == "" {
pid = e.NodeID
}
grouped[pid] = append(grouped[pid], e)
}
return grouped, nil
}

View File

@ -84,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if err != nil {
return nil, err
}
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
}
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 {
var parent interface{}
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
// in the local timezone (the server's timezone, which should match the user's).
// TODO: accept a user timezone offset when multi-user support is added.
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
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.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.
// 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) {
start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes
WHERE deleted_at IS NULL
AND parent_id IS NULL
AND (
(created_at >= ? AND created_at < ?)
OR

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

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