steps 8+9: worklog + FTS5 search
STEP 8 — Worklog: - Migration 006: worklog_entries table (node_id, date, minutes, approximate, billable, summary, details) - WorklogService: Add, Get, Update, Delete, ListByNode, SumMinutes, Report (text report generator with total time) - CLI: verstak log add/list/report (verstak log --help for usage) - GUI tab: entries list with date/time/approx, add form with minutes+text+approx checkbox, total minutes counter STEP 9 — FTS5 Search: - FTS5 virtual table created lazily by search.Rebuild() (works with/without FTS5 compiled in — graceful fallback) - SearchService: Index, Remove, Rebuild, Search (with FTS5 MATCH) - CLI: verstak index rebuild — builds search index from node titles - GUI search bar uses /api/search?q= (FTS5 when available, fallback to LIKE on node titles) Acceptance: go build ./... pass, go test ./... pass (all packages).
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package worklog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
"verstak/internal/core/util"
|
||||
)
|
||||
|
||||
// Entry represents a worklog record.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
NodeID string `json:"node_id"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
EndedAt *time.Time `json:"ended_at,omitempty"`
|
||||
Date string `json:"date"`
|
||||
Minutes *int `json:"minutes,omitempty"`
|
||||
Approximate bool `json:"approximate"`
|
||||
Billable bool `json:"billable"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Service manages worklog entries.
|
||||
type Service struct {
|
||||
db *storage.DB
|
||||
}
|
||||
|
||||
// NewService creates a worklog service.
|
||||
func NewService(db *storage.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// Add inserts a new worklog entry.
|
||||
func (s *Service) Add(nodeID, summary, details 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")
|
||||
}
|
||||
|
||||
e := &Entry{
|
||||
ID: util.UUID7(),
|
||||
NodeID: nodeID,
|
||||
Summary: summary,
|
||||
Details: details,
|
||||
Date: time.Now().UTC().Format("2006-01-02"),
|
||||
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
|
||||
}
|
||||
|
||||
// Update modifies an existing entry.
|
||||
func (s *Service) Update(id, summary, details string, minutes int, approximate, billable bool) error {
|
||||
t := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE worklog_entries SET summary=?, details=?, minutes=?,
|
||||
approximate=?, billable=?, updated_at=? WHERE id=?`,
|
||||
summary, details, &minutes, boolInt(approximate), boolInt(billable), t, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("entry not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns an entry by ID.
|
||||
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
|
||||
FROM worklog_entries WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return nil, fmt.Errorf("entry not found")
|
||||
}
|
||||
return scanEntry(rows)
|
||||
}
|
||||
|
||||
// ListByNode returns entries for a node, newest first.
|
||||
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
|
||||
FROM worklog_entries WHERE node_id=? ORDER BY date DESC, created_at DESC`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Entry
|
||||
for rows.Next() {
|
||||
e, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Delete removes an entry.
|
||||
func (s *Service) Delete(id string) error {
|
||||
res, err := s.db.Exec("DELETE FROM worklog_entries WHERE id=?", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("entry not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SumMinutes returns total minutes for a node.
|
||||
func (s *Service) SumMinutes(nodeID string) (int, error) {
|
||||
var total sql.NullInt64
|
||||
err := s.db.QueryRow(
|
||||
`SELECT SUM(minutes) FROM worklog_entries WHERE node_id=?`, nodeID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !total.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return int(total.Int64), nil
|
||||
}
|
||||
|
||||
// Report generates a text report for a node's worklog.
|
||||
func (s *Service) Report(nodeID string) (string, error) {
|
||||
entries, err := s.ListByNode(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Отчёт по работе (узел: %s)\n", nodeID))
|
||||
b.WriteString(strings.Repeat("─", 40) + "\n\n")
|
||||
|
||||
totalMin := 0
|
||||
for _, e := range entries {
|
||||
date := e.Date
|
||||
duration := "—"
|
||||
if e.Minutes != nil {
|
||||
m := *e.Minutes
|
||||
totalMin += m
|
||||
duration = fmt.Sprintf("%dч %dм", m/60, m%60)
|
||||
}
|
||||
approx := ""
|
||||
if e.Approximate {
|
||||
approx = " ~"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s %s%s\n", date, duration, approx))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", e.Summary))
|
||||
if e.Details != "" {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", e.Details))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString(strings.Repeat("─", 40) + "\n")
|
||||
b.WriteString(fmt.Sprintf("Итого: %dч %dм\n", totalMin/60, totalMin%60))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// --- scanner ---
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanEntry(s rowScanner) (*Entry, error) {
|
||||
var e Entry
|
||||
var startedAt, endedAt, details 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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if startedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, startedAt.String)
|
||||
e.StartedAt = &t
|
||||
}
|
||||
if endedAt.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, endedAt.String)
|
||||
e.EndedAt = &t
|
||||
}
|
||||
if minutes.Valid {
|
||||
m := int(minutes.Int64)
|
||||
e.Minutes = &m
|
||||
}
|
||||
e.Approximate = approxInt == 1
|
||||
e.Billable = billInt == 1
|
||||
if details.Valid {
|
||||
e.Details = details.String
|
||||
}
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||
e.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package worklog
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"verstak/internal/core/storage"
|
||||
)
|
||||
|
||||
func openTestDB(t *testing.T) *storage.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := storage.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAddAndGet(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
e, err := svc.Add("node-1", "Updated website", "Changed banner and products", 180, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if e.ID == "" {
|
||||
t.Fatal("empty id")
|
||||
}
|
||||
if e.Minutes == nil || *e.Minutes != 180 {
|
||||
t.Errorf("minutes = %v, want 180", e.Minutes)
|
||||
}
|
||||
if !e.Approximate {
|
||||
t.Error("expected approximate")
|
||||
}
|
||||
if e.Date != time.Now().UTC().Format("2006-01-02") {
|
||||
t.Errorf("date = %q", e.Date)
|
||||
}
|
||||
|
||||
got, err := svc.Get(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got.ID = e.ID
|
||||
got.CreatedAt = e.CreatedAt
|
||||
got.UpdatedAt = e.UpdatedAt
|
||||
if got.Summary != "Updated website" {
|
||||
t.Errorf("summary = %q", got.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByNode(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
svc.Add("node-1", "Work A", "", 60, false, false)
|
||||
svc.Add("node-1", "Work B", "", 120, true, false)
|
||||
svc.Add("node-2", "Work C", "", 30, false, false)
|
||||
|
||||
list1, _ := svc.ListByNode("node-1")
|
||||
if len(list1) != 2 {
|
||||
t.Errorf("node-1 entries = %d, want 2", len(list1))
|
||||
}
|
||||
|
||||
list2, _ := svc.ListByNode("node-2")
|
||||
if len(list2) != 1 {
|
||||
t.Errorf("node-2 entries = %d, want 1", len(list2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
e, _ := svc.Add("node-1", "Old text", "Old details", 60, false, false)
|
||||
err := svc.Update(e.ID, "New text", "New details", 90, true, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := svc.Get(e.ID)
|
||||
if got.Summary != "New text" {
|
||||
t.Errorf("summary = %q", got.Summary)
|
||||
}
|
||||
if got.Details != "New details" {
|
||||
t.Errorf("details = %q", got.Details)
|
||||
}
|
||||
if *got.Minutes != 90 {
|
||||
t.Errorf("minutes = %d", *got.Minutes)
|
||||
}
|
||||
if !got.Approximate {
|
||||
t.Error("expected approximate")
|
||||
}
|
||||
if !got.Billable {
|
||||
t.Error("expected billable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
e, _ := svc.Add("node-1", "To delete", "", 10, false, false)
|
||||
if err := svc.Delete(e.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Get(e.ID); err == nil {
|
||||
t.Error("expected error after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSumMinutes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
svc.Add("node-1", "A", "", 60, false, false)
|
||||
svc.Add("node-1", "B", "", 120, false, false)
|
||||
svc.Add("node-1", "C", "", 30, false, false)
|
||||
|
||||
total, err := svc.SumMinutes("node-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 210 {
|
||||
t.Errorf("total = %d, want 210", total)
|
||||
}
|
||||
|
||||
total2, _ := svc.SumMinutes("node-2")
|
||||
if total2 != 0 {
|
||||
t.Errorf("empty total = %d, want 0", total2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReport(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
svc := NewService(db)
|
||||
|
||||
svc.Add("node-1", "Updated homepage", "Changed hero section", 90, true, false)
|
||||
svc.Add("node-1", "Fixed bug", "Login redirect", 30, false, true)
|
||||
|
||||
report, err := svc.Report("node-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report == "" {
|
||||
t.Error("empty report")
|
||||
}
|
||||
// Should contain total: 2h 0m (90+30=120min)
|
||||
if !contains(report, "2ч 0м") {
|
||||
t.Errorf("report missing total:\n%s", report)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user