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>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user