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 }