package service import ( "context" "database/sql" "errors" "fmt" "time" "github.com/google/uuid" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) // Sentinel errors returned by the service layer. var ( ErrEntryRunning = errors.New("an entry is already running") ErrEntryNotRunning = errors.New("no running entry found") ErrEntryNotFound = errors.New("entry not found") ErrDayAlreadyClosed = errors.New("day is already closed") ErrDayNotClosed = errors.New("day is not closed") ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first") ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time") ) // EntryService handles business logic for time entries. type EntryService struct { entries *store.EntryStore closedDays *store.ClosedDayStore settings *store.SettingsStore tz *time.Location } func NewEntryService( entries *store.EntryStore, closedDays *store.ClosedDayStore, settings *store.SettingsStore, tz *time.Location, ) *EntryService { return &EntryService{ entries: entries, closedDays: closedDays, settings: settings, tz: tz, } } func (s *EntryService) nowMs() int64 { return time.Now().UnixMilli() } func (s *EntryService) dayKeyForMs(ms int64) string { t := time.UnixMilli(ms).In(s.tz) return t.Format("2006-01-02") } // midnightEndMs returns the unix ms of 23:59:59.999 for the day containing ms in the configured tz. func (s *EntryService) midnightEndMs(ms int64) int64 { t := time.UnixMilli(ms).In(s.tz) end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999_000_000, s.tz) return end.UnixMilli() } // Start creates a new running entry. Returns ErrEntryRunning if one is already active. func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, error) { running, err := s.entries.RunningEntry(ctx) if err != nil { return nil, err } if running != nil { return nil, ErrEntryRunning } nowMs := s.nowMs() dayKey := s.dayKeyForMs(nowMs) // Check day is not already closed closed, err := s.closedDays.GetByDayKey(ctx, dayKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } if closed != nil { return nil, ErrDayAlreadyClosed } e := &domain.Entry{ ID: uuid.New().String(), StartTime: nowMs, Note: note, DayKey: dayKey, UpdatedAt: nowMs, } if err := s.entries.Create(ctx, e); err != nil { return nil, err } return e, nil } // Stop stops the currently running entry. Returns ErrEntryNotRunning if none active. func (s *EntryService) Stop(ctx context.Context) (*domain.Entry, error) { running, err := s.entries.RunningEntry(ctx) if err != nil { return nil, err } if running == nil { return nil, ErrEntryNotRunning } return s.stopEntry(ctx, running, false) } // StopByID stops a specific entry by ID. func (s *EntryService) StopByID(ctx context.Context, id string) (*domain.Entry, error) { e, err := s.entries.GetByID(ctx, id) if err == sql.ErrNoRows { return nil, ErrEntryNotFound } if err != nil { return nil, err } if !e.IsRunning() { return nil, ErrEntryNotRunning } return s.stopEntry(ctx, e, false) } func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopped bool) (*domain.Entry, error) { nowMs := s.nowMs() // Enforce same-day rule: cap end_time at 23:59:59.999 of start day endMs := nowMs startDayKey := s.dayKeyForMs(e.StartTime) endDayKey := s.dayKeyForMs(endMs) if endDayKey != startDayKey { // Cross-midnight: cap at end of start day endMs = s.midnightEndMs(e.StartTime) autoStopped = true } e.EndTime = &endMs e.AutoStopped = autoStopped e.UpdatedAt = nowMs if err := s.entries.Update(ctx, e); err != nil { return nil, err } return e, nil } // GetByID returns a single entry. func (s *EntryService) GetByID(ctx context.Context, id string) (*domain.Entry, error) { e, err := s.entries.GetByID(ctx, id) if err == sql.ErrNoRows { return nil, ErrEntryNotFound } return e, err } // List returns entries within a date range. func (s *EntryService) List(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) { return s.entries.ListByDateRange(ctx, fromDayKey, toDayKey) } // UpdateEntry allows editing start/end/note for a non-running, non-closed entry. type UpdateEntryInput struct { StartTime *int64 EndTime *int64 Note *string } func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryInput) (*domain.Entry, error) { e, err := s.entries.GetByID(ctx, id) if err == sql.ErrNoRows { return nil, ErrEntryNotFound } if err != nil { return nil, err } if input.StartTime != nil { e.StartTime = *input.StartTime e.DayKey = s.dayKeyForMs(e.StartTime) } if input.EndTime != nil { // Validate same-day startDayKey := s.dayKeyForMs(e.StartTime) endDayKey := s.dayKeyForMs(*input.EndTime) if startDayKey != endDayKey { return nil, ErrCrossesMidnight } if *input.EndTime < e.StartTime { return nil, fmt.Errorf("end_time must be after start_time") } e.EndTime = input.EndTime } if input.Note != nil { e.Note = *input.Note } e.UpdatedAt = s.nowMs() if err := s.entries.Update(ctx, e); err != nil { return nil, err } return e, nil } // Delete soft-deletes an entry. func (s *EntryService) Delete(ctx context.Context, id string) error { nowMs := s.nowMs() return s.entries.SoftDelete(ctx, id, nowMs) } // AutoStopStalledEntries stops any running entries whose day_key is before today. // Called by the midnight background goroutine. func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) { today := s.dayKeyForMs(s.nowMs()) return s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs()) }