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