today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar
This commit is contained in:
parent
69891e395c
commit
c74fa3ad43
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue