today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar
This commit is contained in:
+337
-8
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user