package service import ( "context" "database/sql" "errors" "time" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) var ( ErrWeekAlreadyClosed = errors.New("week is already closed") ErrWeekNotClosed = errors.New("week is not closed") ErrWeekHasUnclosedDays = errors.New("all workdays in the week must be closed before closing the week") ErrDayHasNoEntries = errors.New("no completed entries found for this day") ) // DayService handles closing and marking days. type DayService struct { entries *store.EntryStore closedDays *store.ClosedDayStore closedWeeks *store.ClosedWeekStore settings *store.SettingsStore tz *time.Location } func NewDayService( entries *store.EntryStore, closedDays *store.ClosedDayStore, closedWeeks *store.ClosedWeekStore, settings *store.SettingsStore, tz *time.Location, ) *DayService { return &DayService{ entries: entries, closedDays: closedDays, closedWeeks: closedWeeks, settings: settings, tz: tz, } } // recomputeWeek updates worked_ms/delta_ms on the closed week containing dayKey, // if that week is already closed. Called after any day close/reopen. func (s *DayService) recomputeWeek(ctx context.Context, dayKey string) error { weekKey, err := WeekKeyForDayKey(dayKey, s.tz) if err != nil { return err } cw, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil // week not closed yet — nothing to do } return err } // Recompute worked_ms from all closed days in the week. dayKeys, err := weekDayKeys(weekKey, s.tz) if err != nil { return err } var totalWorkedMs int64 for _, dk := range dayKeys { cd, err := s.closedDays.GetByDayKey(ctx, dk) if err != nil { if errors.Is(err, sql.ErrNoRows) { continue } return err } totalWorkedMs += cd.WorkedMs } cw.WorkedMs = totalWorkedMs cw.DeltaMs = totalWorkedMs - cw.ExpectedMs cw.UpdatedAt = time.Now().UnixMilli() return s.closedWeeks.Upsert(ctx, cw) } // CloseDay merges all completed entries for the given day key into a ClosedDay. // Returns ErrRunningEntryOnDay if a running entry exists. // Returns ErrDayAlreadyClosed if already closed. func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) { // Already closed? existing, err := s.closedDays.GetByDayKey(ctx, dayKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } if existing != nil { return nil, ErrDayAlreadyClosed } // Running entry? running, err := s.entries.RunningEntryForDay(ctx, dayKey) if err != nil { return nil, err } if running != nil { return nil, ErrRunningEntryOnDay } // Load all completed entries for the day entries, err := s.entries.ListByDayKey(ctx, dayKey) if err != nil { return nil, err } if len(entries) == 0 { return nil, ErrDayHasNoEntries } var minStart, maxEnd, totalMs int64 minStart = entries[0].StartTime for _, e := range entries { if e.EndTime == nil { continue } if e.StartTime < minStart { minStart = e.StartTime } if *e.EndTime > maxEnd { maxEnd = *e.EndTime } totalMs += e.DurationMs() } now := time.Now().UnixMilli() cd := &domain.ClosedDay{ DayKey: dayKey, StartTime: &minStart, EndTime: &maxEnd, WorkedMs: totalMs, Kind: domain.DayKindWork, ClosedAt: now, UpdatedAt: now, } if err := s.closedDays.Upsert(ctx, cd); err != nil { return nil, err } if err := s.recomputeWeek(ctx, dayKey); err != nil { return nil, err } return cd, nil } // MarkDay closes a day as holiday, vacation, or sick. // worked_ms is set to the expected daily ms from settings at that date. func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.DayKind) (*domain.ClosedDay, error) { if !kind.Valid() || kind == domain.DayKindWork { return nil, errors.New("kind must be one of: holiday, vacation, sick") } // Lookup settings effective on that day set, err := s.settings.Current(ctx, dayKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } var workedMs int64 if set != nil { // Parse the weekday from dayKey t, err := time.ParseInLocation("2006-01-02", dayKey, s.tz) if err == nil && set.IsWorkday(int(t.Weekday())) { workedMs = set.DailyExpectedMs() } } now := time.Now().UnixMilli() cd := &domain.ClosedDay{ DayKey: dayKey, WorkedMs: workedMs, Kind: kind, ClosedAt: now, UpdatedAt: now, } if err := s.closedDays.Upsert(ctx, cd); err != nil { return nil, err } if err := s.recomputeWeek(ctx, dayKey); err != nil { return nil, err } return cd, nil } // ReopenDay deletes the closed_days row for a day, making it editable again. func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error { existing, err := s.closedDays.GetByDayKey(ctx, dayKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrDayNotClosed } return err } if existing == nil { return ErrDayNotClosed } if err := s.closedDays.Delete(ctx, dayKey); err != nil { return err } return s.recomputeWeek(ctx, dayKey) } // GetDay returns the closed day for a given day key, or nil if open. func (s *DayService) GetDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) { cd, err := s.closedDays.GetByDayKey(ctx, dayKey) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return cd, err } // ListDays returns closed days within a date range. func (s *DayService) ListDays(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) { return s.closedDays.ListByDateRange(ctx, fromDayKey, toDayKey) }