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"` 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 } // NewService creates a worklog service. func NewService(db *storage.DB) *Service { return &Service{db: db} } // 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: 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,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, e.ID, e.NodeID, e.Date, e.Minutes, boolInt(e.Approximate), boolInt(e.Billable), e.Summary, e.Details, e.Source, 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,COALESCE(source,'unknown'),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,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 } 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 } // HasTodayEntries checks if any worklog entries exist for today. func (s *Service) HasTodayEntries(nodeID string) (bool, error) { today := time.Now().Format("2006-01-02") var count int err := s.db.QueryRow( `SELECT COUNT(*) FROM worklog_entries WHERE node_id=? AND date=?`, nodeID, today).Scan(&count) return count > 0, err } // 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, 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, &source, &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.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 } func boolInt(b bool) int { if b { return 1 } return 0 }