feat: aggregate journals across node subtrees
This commit is contained in:
parent
23f517dee3
commit
10b287de7b
|
|
@ -197,6 +197,7 @@ type WorklogDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
NodeTitle string `json:"nodeTitle,omitempty"`
|
NodeTitle string `json:"nodeTitle,omitempty"`
|
||||||
|
NodePath string `json:"nodePath,omitempty"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Minutes int `json:"minutes"`
|
Minutes int `json:"minutes"`
|
||||||
Date string `json:"date,omitempty"`
|
Date string `json:"date,omitempty"`
|
||||||
|
|
@ -217,6 +218,7 @@ type SearchResultDTO struct {
|
||||||
type EventDTO struct {
|
type EventDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
|
NodePath string `json:"nodePath,omitempty"`
|
||||||
EventType string `json:"eventType"`
|
EventType string `json:"eventType"`
|
||||||
TargetType string `json:"targetType"`
|
TargetType string `json:"targetType"`
|
||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
|
||||||
}
|
}
|
||||||
result := make([]EventDTO, len(events))
|
result := make([]EventDTO, len(events))
|
||||||
for i, e := range events {
|
for i, e := range events {
|
||||||
result[i] = toEventDTO(e)
|
result[i] = a.eventDTOWithPath(e)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +166,13 @@ func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO,
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
events, err := a.activity.ListByNode(nodeID, limit, offset)
|
events, err := a.listActivityByNodeSubtree(nodeID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := make([]EventDTO, len(events))
|
result := make([]EventDTO, len(events))
|
||||||
for i, e := range events {
|
for i, e := range events {
|
||||||
result[i] = toEventDTO(e)
|
result[i] = a.eventDTOWithPath(e)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
@ -185,3 +185,38 @@ func (a *App) CountActivityByNode(nodeID string) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = syncsvc.EntityNode
|
var _ = syncsvc.EntityNode
|
||||||
|
|
||||||
|
func (a *App) listActivityByNodeSubtree(nodeID string, limit, offset int) ([]activity.Event, error) {
|
||||||
|
rows, err := a.db.Query(
|
||||||
|
`WITH RECURSIVE subtree(id) AS (
|
||||||
|
SELECT id FROM nodes WHERE id = ? AND deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT n.id FROM nodes n JOIN subtree s ON n.parent_id = s.id
|
||||||
|
WHERE n.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
SELECT e.id, e.node_id, e.event_type, COALESCE(e.target_type,''), COALESCE(e.target_id,''), COALESCE(e.target_path,''),
|
||||||
|
e.title, COALESCE(e.metadata,'{}'), e.created_at
|
||||||
|
FROM activity_events e
|
||||||
|
JOIN subtree s ON s.id = e.node_id
|
||||||
|
ORDER BY e.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`, nodeID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var events []activity.Event
|
||||||
|
for rows.Next() {
|
||||||
|
var e activity.Event
|
||||||
|
if err := rows.Scan(&e.ID, &e.NodeID, &e.EventType, &e.TargetType, &e.TargetID, &e.TargetPath, &e.Title, &e.DetailsJSON, &e.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) eventDTOWithPath(e activity.Event) EventDTO {
|
||||||
|
dto := toEventDTO(e)
|
||||||
|
dto.NodePath = a.nodes.Path(e.NodeID)
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,16 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rows, err = a.db.Query(`SELECT DISTINCT event_id FROM worklog_dismissed_events`)
|
||||||
|
if err == nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var eid string
|
||||||
|
if rows.Scan(&eid) == nil {
|
||||||
|
accounted[eid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type acc struct {
|
type acc struct {
|
||||||
title string
|
title string
|
||||||
|
|
@ -90,6 +100,7 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
CreatedAt: e.CreatedAt,
|
CreatedAt: e.CreatedAt,
|
||||||
NodeID: e.NodeID,
|
NodeID: e.NodeID,
|
||||||
|
NodePath: a.nodes.Path(e.NodeID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +126,38 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
|
||||||
return suggestions, nil
|
return suggestions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) DismissSuggestion(nodeID, eventIDsJSON string) error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var eventIDs []string
|
||||||
|
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal eventIDs: %w", err)
|
||||||
|
}
|
||||||
|
if len(eventIDs) == 0 {
|
||||||
|
return fmt.Errorf("eventIDs required")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, eventID := range eventIDs {
|
||||||
|
var n int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, nodeID).Scan(&n); err != nil {
|
||||||
|
return fmt.Errorf("check event %s: %w", eventID, err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("event %s not found for node", eventID)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO worklog_dismissed_events(event_id,node_id,created_at) VALUES(?,?,?)`, eventID, nodeID, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
|
||||||
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,15 @@ func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
|
||||||
if err := a.requireVault(); err != nil {
|
if err := a.requireVault(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
list, err := a.worklog.ListByNode(nodeID)
|
rows, err := a.worklog.ListReport(worklog.ReportFilter{NodeID: nodeID, IncludeChildren: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toWorklogDTOs(list), nil
|
result := make([]WorklogDTO, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
result = append(result, reportRowToWorklogDTO(row))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
|
||||||
|
|
@ -264,3 +268,20 @@ func entryToDTO(e *worklog.Entry) *WorklogDTO {
|
||||||
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportRowToWorklogDTO(r worklog.ReportRow) WorklogDTO {
|
||||||
|
return WorklogDTO{
|
||||||
|
ID: r.ID,
|
||||||
|
NodeID: r.NodeID,
|
||||||
|
NodeTitle: r.NodeTitle,
|
||||||
|
NodePath: r.NodePath,
|
||||||
|
Summary: r.Summary,
|
||||||
|
Minutes: r.Minutes,
|
||||||
|
Date: r.Date,
|
||||||
|
Details: r.Details,
|
||||||
|
Approximate: r.Approximate,
|
||||||
|
Billable: r.Billable,
|
||||||
|
Source: r.Source,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
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
|
|
@ -19,8 +19,8 @@
|
||||||
background: #13131f;
|
background: #13131f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/assets/main-BQHjHDrT.js"></script>
|
<script type="module" crossorigin src="/assets/main-DOH0BsUz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-DfazBFdN.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DRlK-DBn.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,112 @@ func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) {
|
||||||
|
app, _ := setupTestApp(t)
|
||||||
|
|
||||||
|
parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent: %v", err)
|
||||||
|
}
|
||||||
|
child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create child: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorklogFull: %v", err)
|
||||||
|
}
|
||||||
|
eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл")
|
||||||
|
|
||||||
|
parentLog, err := app.ListWorklog(parent.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListWorklog(parent): %v", err)
|
||||||
|
}
|
||||||
|
if len(parentLog) != 1 || parentLog[0].ID != entry.ID {
|
||||||
|
t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID)
|
||||||
|
}
|
||||||
|
if parentLog[0].NodeID != child.ID {
|
||||||
|
t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID)
|
||||||
|
}
|
||||||
|
if parentLog[0].NodePath != "Parent Project > Documents" {
|
||||||
|
t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivityByNode(parent): %v", err)
|
||||||
|
}
|
||||||
|
var foundEvent *EventDTO
|
||||||
|
for i := range parentActivity {
|
||||||
|
if parentActivity[i].ID == eventID {
|
||||||
|
foundEvent = &parentActivity[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundEvent == nil {
|
||||||
|
t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID)
|
||||||
|
}
|
||||||
|
if foundEvent.NodePath != "Parent Project > Documents" {
|
||||||
|
t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var physicalEvents int
|
||||||
|
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil {
|
||||||
|
t.Fatalf("count physical event: %v", err)
|
||||||
|
}
|
||||||
|
if physicalEvents != 1 {
|
||||||
|
t.Fatalf("physical child event count = %d, want 1", physicalEvents)
|
||||||
|
}
|
||||||
|
var copiedToParent int
|
||||||
|
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil {
|
||||||
|
t.Fatalf("count copied parent event: %v", err)
|
||||||
|
}
|
||||||
|
if copiedToParent != 0 {
|
||||||
|
t.Fatalf("copied parent events = %d, want 0", copiedToParent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) {
|
||||||
|
app, _ := setupTestApp(t)
|
||||||
|
|
||||||
|
n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки")
|
||||||
|
|
||||||
|
if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil {
|
||||||
|
t.Fatalf("DismissSuggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, err := app.GetSuggestions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSuggestions: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range suggestions {
|
||||||
|
if s.NodeID == n.ID {
|
||||||
|
t.Fatalf("dismissed suggestion still visible: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventCount int
|
||||||
|
if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil {
|
||||||
|
t.Fatalf("count event: %v", err)
|
||||||
|
}
|
||||||
|
if eventCount != 1 {
|
||||||
|
t.Fatalf("activity event count = %d, want 1", eventCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, value any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal json: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
func TestApplyRemoteWorklogUpdate(t *testing.T) {
|
||||||
app, _ := setupTestApp(t)
|
app, _ := setupTestApp(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1186,6 +1186,15 @@
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSuggestion(s) {
|
||||||
|
try {
|
||||||
|
await wailsCall('DismissSuggestion', s.nodeId, JSON.stringify(extractEventIds(s)))
|
||||||
|
await refreshAfterSuggestion()
|
||||||
|
} catch (e) {
|
||||||
|
error = String(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeDebugLog(msg) {
|
function writeDebugLog(msg) {
|
||||||
try { wailsCall('WriteDebugLog', msg) } catch(e) {}
|
try { wailsCall('WriteDebugLog', msg) } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
@ -2468,14 +2477,18 @@
|
||||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>
|
||||||
{t('worklog.apply')}
|
{t('worklog.apply')}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if s._expanded && s.events && s.events.length > 0}
|
{#if s.events && s.events.length > 0}
|
||||||
<div class="suggestion-detail">
|
<div class="suggestion-detail">
|
||||||
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
||||||
{#each s.events as ev}
|
{#each s.events as ev}
|
||||||
<div class="suggestion-detail-event">
|
<div class="suggestion-detail-event">
|
||||||
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
|
||||||
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
<span class="suggestion-event-title">{ev.title}</span>
|
<span class="suggestion-event-title">{ev.title}</span>
|
||||||
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
||||||
|
|
@ -2498,6 +2511,7 @@
|
||||||
<div class="worklog-entry-head">
|
<div class="worklog-entry-head">
|
||||||
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span>
|
<span class="worklog-toggle">{e._expanded ? '▾' : '▸'}</span>
|
||||||
<span class="worklog-entry-summary">{e.summary}</span>
|
<span class="worklog-entry-summary">{e.summary}</span>
|
||||||
|
{#if e.nodePath}<span class="worklog-entry-path">{e.nodePath}</span>{/if}
|
||||||
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</span>
|
<span class="worklog-entry-mins">{e.minutes} {t('worklog.min')}</span>
|
||||||
{#if e.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
|
{#if e.billable}<span class="wl-tag-billable">{t('journal.billableYes')}</span>{/if}
|
||||||
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
|
{#if e.approximate}<span class="wl-tag-approx">{t('journal.approxEstimated')}</span>{/if}
|
||||||
|
|
@ -2562,6 +2576,7 @@
|
||||||
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
<div class="activity-event" role="button" tabindex="0" on:click={() => openActivityEvent(ev)} on:keydown={(e) => e.key === 'Enter' && openActivityEvent(ev)}>
|
||||||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||||
<span class="activity-event-title">{ev.title}</span>
|
<span class="activity-event-title">{ev.title}</span>
|
||||||
|
{#if ev.nodePath}<span class="activity-event-path">{ev.nodePath}</span>{/if}
|
||||||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||||
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
|
@ -2774,14 +2789,16 @@
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
||||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptJournalSuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
|
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if s._expanded && s.events && s.events.length > 0}
|
{#if s.events && s.events.length > 0}
|
||||||
<div class="suggestion-detail">
|
<div class="suggestion-detail">
|
||||||
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
||||||
{#each s.events as ev}
|
{#each s.events as ev}
|
||||||
<div class="suggestion-detail-event">
|
<div class="suggestion-detail-event">
|
||||||
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
|
||||||
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
<span class="suggestion-event-title">{ev.title}</span>
|
<span class="suggestion-event-title">{ev.title}</span>
|
||||||
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
||||||
|
|
@ -2933,14 +2950,16 @@
|
||||||
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
<span class="suggestion-min-label">{t('suggest.minutes')}</span>
|
||||||
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
<button class="btn btn-sm" on:click|stopPropagation={() => openSuggestionWorklogModal(s)}>{t('suggest.edit')}</button>
|
||||||
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
<button class="btn btn-sm btn-primary" on:click|stopPropagation={() => acceptTodaySuggestion(s)}>{t('suggest.apply')}</button>
|
||||||
|
<button class="btn btn-sm btn-danger" on:click|stopPropagation={() => deleteSuggestion(s)}>{t('common.delete')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if s._expanded && s.events && s.events.length > 0}
|
{#if s.events && s.events.length > 0}
|
||||||
<div class="suggestion-detail">
|
<div class="suggestion-detail">
|
||||||
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
<div class="suggestion-detail-title">{t('suggest.detectedEvents')}</div>
|
||||||
{#each s.events as ev}
|
{#each s.events as ev}
|
||||||
<div class="suggestion-detail-event">
|
<div class="suggestion-detail-event">
|
||||||
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
|
||||||
|
{#if ev.nodePath}<span class="suggestion-event-path">{ev.nodePath}</span>{/if}
|
||||||
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
|
||||||
<span class="suggestion-event-title">{ev.title}</span>
|
<span class="suggestion-event-title">{ev.title}</span>
|
||||||
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
|
||||||
|
|
@ -3445,15 +3464,16 @@
|
||||||
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
.suggestion-meta { font-size: 12px; color: #8888a0; margin-top: 2px; }
|
||||||
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
|
.suggestion-main { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; cursor: pointer; gap: 12px; }
|
||||||
.suggestion-card.expanded { border-color: #3a3a5c; }
|
.suggestion-card.expanded { border-color: #3a3a5c; }
|
||||||
.suggestion-detail { padding: 0 12px 10px; border-top: 1px solid #2a2a3c; }
|
.suggestion-detail { width: 100%; padding: 8px 12px 10px; border-top: 1px solid #2a2a3c; }
|
||||||
.suggestion-detail-title { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 0 4px; }
|
.suggestion-detail-title { font-size: 11px; font-weight: 600; color: #a5b4fc; text-transform: uppercase; letter-spacing: 0.3px; padding: 8px 0 4px; }
|
||||||
.suggestion-detail-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; color: #b0b0c0; }
|
.suggestion-detail-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; color: #b0b0c0; }
|
||||||
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
|
.suggestion-event-time { color: #a0a0b8; font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 48px; }
|
||||||
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
|
.suggestion-event-type { color: #8888a0; font-size: 11px; background: #1a1a2e; padding: 1px 6px; border-radius: 3px; }
|
||||||
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.suggestion-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.suggestion-event-path { max-width: 220px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* Suggestion cards */
|
/* Suggestion cards */
|
||||||
.suggestion-card { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 12px; }
|
.suggestion-card { display: flex; flex-direction: column; align-items: stretch; padding: 0; background: #1e1e32; border-radius: 6px; margin-bottom: 8px; gap: 0; border: 1px solid transparent; }
|
||||||
.suggestion-card:last-child { margin-bottom: 0; }
|
.suggestion-card:last-child { margin-bottom: 0; }
|
||||||
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
.suggestion-info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
|
.suggestion-node { color: #a5b4fc; font-weight: 600; font-size: 13px; text-decoration: none; cursor: pointer; }
|
||||||
|
|
@ -3739,6 +3759,7 @@
|
||||||
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||||
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||||||
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.activity-event-path { max-width: 240px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||||||
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||||||
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||||||
|
|
@ -3784,6 +3805,7 @@
|
||||||
.worklog-entry-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #e4e4ef; }
|
.worklog-entry-head { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #e4e4ef; }
|
||||||
.worklog-toggle { color: #6366f1; font-size: 12px; width: 16px; text-align: center; flex-shrink: 0; }
|
.worklog-toggle { color: #6366f1; font-size: 12px; width: 16px; text-align: center; flex-shrink: 0; }
|
||||||
.worklog-entry-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.worklog-entry-summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.worklog-entry-path { max-width: 240px; color: #8ea0d8; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.worklog-entry-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.worklog-entry-mins { color: #b0b0c8; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.worklog-entry-date { color: #b0b0c0; font-size: 12px; white-space: nowrap; }
|
.worklog-entry-date { color: #b0b0c0; font-size: 12px; white-space: nowrap; }
|
||||||
.worklog-entry-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }
|
.worklog-entry-detail { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a3c; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ const (
|
||||||
|
|
||||||
// SuggestionDetail is a lightweight event summary for suggestion display.
|
// SuggestionDetail is a lightweight event summary for suggestion display.
|
||||||
type SuggestionDetail struct {
|
type SuggestionDetail struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
EventType string `json:"eventType"`
|
EventType string `json:"eventType"`
|
||||||
TargetType string `json:"targetType"`
|
TargetType string `json:"targetType"`
|
||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
NodeID string `json:"nodeId"`
|
NodeID string `json:"nodeId"`
|
||||||
|
NodePath string `json:"nodePath,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suggestion represents a suggested worklog entry derived from today's activity.
|
// Suggestion represents a suggested worklog entry derived from today's activity.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
// migration016 — dismissed worklog suggestion events.
|
||||||
|
const migration016 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS worklog_dismissed_events (
|
||||||
|
event_id TEXT PRIMARY KEY REFERENCES activity_events(id),
|
||||||
|
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_worklog_dismissed_node ON worklog_dismissed_events(node_id);
|
||||||
|
`
|
||||||
|
|
@ -72,6 +72,7 @@ var migrationFiles = map[int]string{
|
||||||
13: migration013,
|
13: migration013,
|
||||||
14: migration014,
|
14: migration014,
|
||||||
15: migration015,
|
15: migration015,
|
||||||
|
16: migration016,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue