feat: worklog source field, suggestion logic fix, modal form, activity navigation

- Add source column to worklog_entries (migration 014): manual/suggestion/unknown
- GetSuggestions now excludes only events linked in worklog_entry_events,
  not entire nodes — repeated activity same day now produces suggestions
- Manual entry form replaced with '+' button + modal dialog
- Source display shows correct origin (manual/suggestion/unknown/no-events)
- Include-children checkbox hidden when no node selected
- Activity events navigate to specific notes/files instead of just case
- Expandable row reactivity fixed (journalRows/worklog reassignment)
This commit is contained in:
2026-06-03 12:27:50 +08:00
parent 1472bb3e6f
commit fd99dd4f5c
18 changed files with 445 additions and 160 deletions
+6 -40
View File
@@ -8,7 +8,6 @@ import (
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// ReportFilter specifies which worklog entries to include.
@@ -33,6 +32,7 @@ type ReportRow struct {
Minutes int `json:"minutes"`
Approximate bool `json:"approximate"`
Billable bool `json:"billable"`
Source string `json:"source"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
@@ -145,7 +145,7 @@ func (s *Service) buildReportQuery(f ReportFilter) (string, []interface{}) {
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
w.created_at, w.updated_at
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id` +
whereClause +
@@ -170,7 +170,7 @@ func (s *Service) ListReport(f ReportFilter) ([]ReportRow, error) {
var approxInt, billInt int
err := rows.Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr)
&r.Source, &createdStr, &updatedStr)
if err != nil {
return nil, err
}
@@ -349,7 +349,7 @@ func (s *Service) ExportMarkdown(f ReportFilter) (string, error) {
func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
q := `SELECT w.id, w.node_id, COALESCE(n.title,''), w.date, w.summary,
COALESCE(w.details,''), COALESCE(w.minutes,0), w.approximate, w.billable,
w.created_at, w.updated_at
COALESCE(w.source,'unknown'), w.created_at, w.updated_at
FROM worklog_entries w
LEFT JOIN nodes n ON n.id = w.node_id
WHERE w.id = ?`
@@ -358,7 +358,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
var approxInt, billInt int
err := s.db.QueryRow(q, id).Scan(&r.ID, &r.NodeID, &r.NodeTitle, &r.Date,
&r.Summary, &r.Details, &r.Minutes, &approxInt, &billInt,
&createdStr, &updatedStr)
&r.Source, &createdStr, &updatedStr)
if err != nil {
return nil, err
}
@@ -371,41 +371,7 @@ func (s *Service) GetByIDWithNode(id string) (*ReportRow, error) {
// AddWithDate inserts a worklog entry with a specific date.
func (s *Service) AddWithDate(nodeID, summary, details, date string, minutes int, approximate, billable bool) (*Entry, error) {
if nodeID == "" {
return nil, fmt.Errorf("node_id required")
}
if summary == "" {
return nil, fmt.Errorf("summary required")
}
if date == "" {
date = time.Now().Format("2006-01-02")
}
e := &Entry{
ID: util.UUID7(),
NodeID: nodeID,
Summary: summary,
Details: details,
Date: date,
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
return nil, err
}
return e, nil
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
}
// UpdateDate updates the date of an entry.
+31 -9
View File
@@ -22,10 +22,18 @@ type Entry struct {
Billable bool `json:"billable"`
Summary string `json:"summary"`
Details string `json:"details,omitempty"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
SourceManual = "manual"
SourceSuggestion = "suggestion"
SourceImported = "imported"
SourceUnknown = "unknown"
)
// Service manages worklog entries.
type Service struct {
db *storage.DB
@@ -36,34 +44,44 @@ func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Add inserts a new worklog entry.
// Add inserts a new worklog entry with manual source.
func (s *Service) Add(nodeID, summary, details string, minutes int, approximate, billable bool) (*Entry, error) {
date := time.Now().Format("2006-01-02")
return s.AddWithSource(nodeID, summary, details, date, minutes, approximate, billable, SourceManual)
}
// Add inserts a new worklog entry.
func (s *Service) AddWithSource(nodeID, summary, details, date string, minutes int, approximate, billable bool, source string) (*Entry, error) {
if nodeID == "" {
return nil, fmt.Errorf("node_id required")
}
if summary == "" {
return nil, fmt.Errorf("summary required")
}
if date == "" {
date = time.Now().Format("2006-01-02")
}
e := &Entry{
ID: util.UUID7(),
NodeID: nodeID,
Summary: summary,
Details: details,
Date: time.Now().Format("2006-01-02"),
Date: date,
Minutes: &minutes,
Approximate: approximate,
Billable: billable,
Source: source,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
_, err := s.db.Exec(
`INSERT INTO worklog_entries (id,node_id,date,minutes,approximate,billable,
summary,details,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
summary,details,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate),
boolInt(e.Billable), e.Summary, e.Details,
boolInt(e.Billable), e.Summary, e.Details, e.Source,
e.CreatedAt.Format(time.RFC3339), e.UpdatedAt.Format(time.RFC3339),
)
if err != nil {
@@ -94,7 +112,7 @@ func (s *Service) Update(id, summary, details string, minutes int, approximate,
func (s *Service) Get(id string) (*Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
FROM worklog_entries WHERE id=?`, id)
if err != nil {
return nil, err
@@ -110,7 +128,7 @@ func (s *Service) Get(id string) (*Entry, error) {
func (s *Service) ListByNode(nodeID string) ([]Entry, error) {
rows, err := s.db.Query(
`SELECT id,node_id,started_at,ended_at,date,minutes,approximate,
billable,summary,details,created_at,updated_at
billable,summary,details,COALESCE(source,'unknown'),created_at,updated_at
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
if err != nil {
return nil, err
@@ -210,14 +228,14 @@ type rowScanner interface {
func scanEntry(s rowScanner) (*Entry, error) {
var e Entry
var startedAt, endedAt, details sql.NullString
var startedAt, endedAt, details, source sql.NullString
var minutes sql.NullInt64
var createdStr, updatedStr string
var approxInt, billInt int
err := s.Scan(
&e.ID, &e.NodeID, &startedAt, &endedAt, &e.Date, &minutes,
&approxInt, &billInt, &e.Summary, &details, &createdStr, &updatedStr,
&approxInt, &billInt, &e.Summary, &details, &source, &createdStr, &updatedStr,
)
if err != nil {
return nil, err
@@ -239,6 +257,10 @@ func scanEntry(s rowScanner) (*Entry, error) {
if details.Valid {
e.Details = details.String
}
e.Source = SourceUnknown
if source.Valid {
e.Source = source.String
}
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
return &e, nil