package store import ( "context" "database/sql" "fmt" "github.com/wotra/wotra/internal/domain" ) // EntryStore handles persistence for entries. type EntryStore struct { db *sql.DB } func NewEntryStore(db *sql.DB) *EntryStore { return &EntryStore{db: db} } func (s *EntryStore) Create(ctx context.Context, e *domain.Entry) error { _, err := s.db.ExecContext(ctx, `INSERT INTO entries (id, start_time, end_time, auto_stopped, note, day_key, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, e.ID, e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, ) return err } func (s *EntryStore) Update(ctx context.Context, e *domain.Entry) error { _, err := s.db.ExecContext(ctx, `UPDATE entries SET start_time=?, end_time=?, auto_stopped=?, note=?, day_key=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, e.ID, ) return err } func (s *EntryStore) SoftDelete(ctx context.Context, id string, nowMs int64) error { _, err := s.db.ExecContext(ctx, `UPDATE entries SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, nowMs, nowMs, id, ) return err } func (s *EntryStore) GetByID(ctx context.Context, id string) (*domain.Entry, error) { row := s.db.QueryRowContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE id=?`, id) return scanEntry(row) } func (s *EntryStore) ListByDayKey(ctx context.Context, dayKey string) ([]*domain.Entry, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE day_key=? AND deleted_at IS NULL ORDER BY start_time ASC`, dayKey) if err != nil { return nil, err } defer rows.Close() return scanEntries(rows) } func (s *EntryStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE day_key >= ? AND day_key <= ? AND deleted_at IS NULL ORDER BY start_time ASC`, fromDayKey, toDayKey) if err != nil { return nil, err } defer rows.Close() return scanEntries(rows) } // RunningEntry returns the currently running entry (no end_time), if any. func (s *EntryStore) RunningEntry(ctx context.Context) (*domain.Entry, error) { row := s.db.QueryRowContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE end_time IS NULL AND deleted_at IS NULL LIMIT 1`) e, err := scanEntry(row) if err == sql.ErrNoRows { return nil, nil } return e, err } // RunningEntryForDay returns a running entry for a specific day, if any. func (s *EntryStore) RunningEntryForDay(ctx context.Context, dayKey string) (*domain.Entry, error) { row := s.db.QueryRowContext(ctx, `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at FROM entries WHERE day_key=? AND end_time IS NULL AND deleted_at IS NULL LIMIT 1`, dayKey) e, err := scanEntry(row) if err == sql.ErrNoRows { return nil, nil } return e, err } // StopAllRunningBefore stops all entries whose day_key < dayKey with the given end time. // Used for the midnight auto-stop. func (s *EntryStore) StopAllRunningBefore(ctx context.Context, dayKey string, endTimeMs int64, nowMs int64) ([]string, error) { rows, err := s.db.QueryContext(ctx, `SELECT id FROM entries WHERE end_time IS NULL AND deleted_at IS NULL AND day_key < ?`, dayKey) if err != nil { return nil, err } var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return nil, err } ids = append(ids, id) } rows.Close() for _, id := range ids { if _, err := s.db.ExecContext(ctx, `UPDATE entries SET end_time=?, auto_stopped=1, updated_at=? WHERE id=?`, endTimeMs, nowMs, id); err != nil { return nil, fmt.Errorf("auto-stop %s: %w", id, err) } } return ids, nil } func scanEntry(row *sql.Row) (*domain.Entry, error) { var e domain.Entry var endTime sql.NullInt64 var deletedAt sql.NullInt64 var autoStopped int err := row.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt) if err != nil { return nil, err } if endTime.Valid { e.EndTime = &endTime.Int64 } if deletedAt.Valid { e.DeletedAt = &deletedAt.Int64 } e.AutoStopped = autoStopped != 0 return &e, nil } func scanEntries(rows *sql.Rows) ([]*domain.Entry, error) { var result []*domain.Entry for rows.Next() { var e domain.Entry var endTime sql.NullInt64 var deletedAt sql.NullInt64 var autoStopped int if err := rows.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt); err != nil { return nil, err } if endTime.Valid { e.EndTime = &endTime.Int64 } if deletedAt.Valid { e.DeletedAt = &deletedAt.Int64 } e.AutoStopped = autoStopped != 0 result = append(result, &e) } return result, rows.Err() } func boolToInt(b bool) int { if b { return 1 } return 0 }