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

This commit is contained in:
2026-06-01 02:16:13 +08:00
parent 69891e395c
commit c74fa3ad43
27 changed files with 665 additions and 98 deletions
+337 -8
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
+2 -2
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>
+11 -8
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{