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>
217 lines
5.9 KiB
Go
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
|
|
}
|