Files
task-master/internal/sqlite/time.go
T
skrueckenandCursor 4adfcdb95a Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:23:11 +01:00

217 lines
5.9 KiB
Go

package sqlite
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"task/internal/ticket"
)
// TimeStore implements ticket.TimeStore on SQLite.
type TimeStore struct {
db *DB
}
// NewTimeStore returns a time store.
func NewTimeStore(db *DB) *TimeStore {
return &TimeStore{db: db}
}
func scanEntry(scanner interface{ Scan(dest ...any) error }) (ticket.TimeEntry, error) {
var e ticket.TimeEntry
var start, end string
var dur int64
err := scanner.Scan(&e.ID, &e.TicketID, &e.ProjectID, &start, &end, &dur, &e.Source, &e.Note, &e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
e.Start = parseTime(start)
e.End = parseTime(end)
e.Duration = time.Duration(dur)
return e, nil
}
const entryCols = `id, ticket_id, project_id, start, end, duration_ns, source, note, external_id`
// List returns all time entries.
func (s *TimeStore) List() ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT ` + entryCols + ` FROM time_entries ORDER BY start DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByTicket returns entries for one ticket.
func (s *TimeStore) ListByTicket(ticketID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE ticket_id = ? ORDER BY start DESC`, ticketID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
// ListByProject returns entries for a project.
func (s *TimeStore) ListByProject(projectID string) ([]ticket.TimeEntry, error) {
rows, err := s.db.SQL.Query(`SELECT `+entryCols+` FROM time_entries WHERE project_id = ? ORDER BY start DESC`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
return collectEntries(rows)
}
func collectEntries(rows *sql.Rows) ([]ticket.TimeEntry, error) {
var out []ticket.TimeEntry
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, err
}
out = append(out, e)
}
if out == nil {
out = []ticket.TimeEntry{}
}
return out, rows.Err()
}
// Create adds a time entry.
func (s *TimeStore) Create(e ticket.TimeEntry) (ticket.TimeEntry, error) {
if e.TicketID == "" {
return ticket.TimeEntry{}, fmt.Errorf("ticketId is required")
}
if e.Duration <= 0 && !e.End.IsZero() && !e.Start.IsZero() {
e.Duration = e.End.Sub(e.Start)
}
if e.Duration <= 0 {
return ticket.TimeEntry{}, fmt.Errorf("duration must be positive")
}
if e.ID == "" {
e.ID = uuid.NewString()
}
if e.Source == "" {
e.Source = ticket.SourceManual
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
if e.End.IsZero() {
e.End = e.Start.Add(e.Duration)
}
_, err := s.db.SQL.Exec(`INSERT INTO time_entries(`+entryCols+`) VALUES(?,?,?,?,?,?,?,?,?)`,
e.ID, e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End),
int64(e.Duration), e.Source, e.Note, e.ExternalID)
if err != nil {
return ticket.TimeEntry{}, err
}
return e, nil
}
// Update replaces a time entry.
func (s *TimeStore) Update(e ticket.TimeEntry) error {
if e.ID == "" {
return fmt.Errorf("time entry id is required")
}
if e.Duration <= 0 {
return fmt.Errorf("duration must be positive")
}
if e.Start.IsZero() {
e.Start = time.Now().UTC()
}
e.End = e.Start.Add(e.Duration)
res, err := s.db.SQL.Exec(`
UPDATE time_entries SET ticket_id=?, project_id=?, start=?, end=?, duration_ns=?, source=?, note=?, external_id=?
WHERE id=?`,
e.TicketID, e.ProjectID, formatTime(e.Start), formatTime(e.End), int64(e.Duration), e.Source, e.Note, e.ExternalID, e.ID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", e.ID)
}
return nil
}
// Delete removes a time entry.
func (s *TimeStore) Delete(id string) error {
res, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("time entry %s: not found", id)
}
return nil
}
// RetargetTicket updates project_id for all entries of a ticket.
func (s *TimeStore) RetargetTicket(ticketID, projectID string) error {
if ticketID == "" || projectID == "" {
return fmt.Errorf("ticketId and projectId are required")
}
_, err := s.db.SQL.Exec(`UPDATE time_entries SET project_id = ? WHERE ticket_id = ?`, projectID, ticketID)
return err
}
// DeleteByProject removes all entries for a project.
func (s *TimeStore) DeleteByProject(projectID string) error {
if projectID == "" {
return fmt.Errorf("projectId is required")
}
_, err := s.db.SQL.Exec(`DELETE FROM time_entries WHERE project_id = ?`, projectID)
return err
}
// SumByTicket returns total duration.
func (s *TimeStore) SumByTicket(ticketID string) (time.Duration, error) {
var sum sql.NullInt64
err := s.db.SQL.QueryRow(`SELECT SUM(duration_ns) FROM time_entries WHERE ticket_id = ?`, ticketID).Scan(&sum)
if err != nil {
return 0, err
}
if !sum.Valid {
return 0, nil
}
return time.Duration(sum.Int64), nil
}
// LoadActiveTimer returns the running timer or nil.
func (s *TimeStore) LoadActiveTimer() (*ticket.ActiveTimer, error) {
var t ticket.ActiveTimer
var started string
err := s.db.SQL.QueryRow(`SELECT ticket_id, project_id, started_at FROM active_timer WHERE id = 1`).
Scan(&t.TicketID, &t.ProjectID, &started)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
t.StartedAt = parseTime(started)
if t.TicketID == "" {
return nil, nil
}
return &t, nil
}
// SaveActiveTimer writes or clears the active timer.
func (s *TimeStore) SaveActiveTimer(t *ticket.ActiveTimer) error {
if t == nil {
_, err := s.db.SQL.Exec(`DELETE FROM active_timer WHERE id = 1`)
return err
}
_, err := s.db.SQL.Exec(`
INSERT INTO active_timer(id, ticket_id, project_id, started_at) VALUES(1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ticket_id=excluded.ticket_id, project_id=excluded.project_id, started_at=excluded.started_at`,
t.TicketID, t.ProjectID, formatTime(t.StartedAt))
return err
}