package service import ( "context" "database/sql" "errors" "fmt" "time" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) // WeekService handles closing weeks and computing overtime/undertime. type WeekService struct { closedDays *store.ClosedDayStore closedWeeks *store.ClosedWeekStore entries *store.EntryStore settings *store.SettingsStore db interface { QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row } rawDB *sql.DB tz *time.Location } func NewWeekService( closedDays *store.ClosedDayStore, closedWeeks *store.ClosedWeekStore, entries *store.EntryStore, settings *store.SettingsStore, rawDB *sql.DB, tz *time.Location, ) *WeekService { return &WeekService{ closedDays: closedDays, closedWeeks: closedWeeks, entries: entries, settings: settings, rawDB: rawDB, tz: tz, } } // WeekDayKeysExported is exported for testing. var WeekDayKeysExported = weekDayKeys // weekDayKeys returns the YYYY-MM-DD keys for Mon-Sun of the ISO week encoded as weekKey. // weekKey format: "YYYY-Www" (e.g. "2024-W03"). func weekDayKeys(weekKey string, tz *time.Location) ([]string, error) { var year, week int if _, err := fmt.Sscanf(weekKey, "%d-W%02d", &year, &week); err != nil { return nil, fmt.Errorf("invalid week_key %q: expected YYYY-Www", weekKey) } // Find the Monday of that ISO week. // Jan 4 is always in week 1 of its year. jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, tz) _, jan4Week := jan4.ISOWeek() monday := jan4.AddDate(0, 0, -int(jan4.Weekday()-time.Monday)+(week-jan4Week)*7) keys := make([]string, 7) for i := 0; i < 7; i++ { keys[i] = monday.AddDate(0, 0, i).Format("2006-01-02") } return keys, nil } // CloseWeek closes an ISO week. All workdays must already be closed. func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) { // Already closed? existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } if existing != nil { return nil, ErrWeekAlreadyClosed } dayKeys, err := weekDayKeys(weekKey, s.tz) if err != nil { return nil, err } // Get settings at the start of the week (Monday) mondayKey := dayKeys[0] set, err := s.settings.Current(ctx, mondayKey) if err != nil { return nil, ErrNoSettings } // Compute expected ms for the week (from settings frozen at week start) expectedMs := int64(set.HoursPerWeek * 3_600_000) // Verify all past workdays that have entries are closed; collect worked ms. // Past workdays with no entries at all are skipped (they contribute 0h). // Future workdays in the week are always skipped. todayKey := time.Now().In(s.tz).Format("2006-01-02") var totalWorkedMs int64 for _, dk := range dayKeys { t, _ := time.ParseInLocation("2006-01-02", dk, s.tz) if !set.IsWorkday(int(t.Weekday())) { continue // weekend or non-workday — skip } if dk > todayKey { continue // future workday — skip } cd, err := s.closedDays.GetByDayKey(ctx, dk) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } if cd != nil { totalWorkedMs += cd.WorkedMs continue } // No closed_days record — check whether the day has any entries. dayEntries, err := s.entries.ListByDayKey(ctx, dk) if err != nil { return nil, err } if len(dayEntries) > 0 { // Day has tracked time but was never closed — require explicit close. return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk) } // No entries, no closed record → untracked day, counts as 0h. } now := time.Now().UnixMilli() cw := &domain.ClosedWeek{ WeekKey: weekKey, ExpectedMs: expectedMs, WorkedMs: totalWorkedMs, DeltaMs: totalWorkedMs - expectedMs, ClosedAt: now, UpdatedAt: now, } if err := s.closedWeeks.Upsert(ctx, cw); err != nil { return nil, err } return cw, nil } // ReopenWeek deletes the closed_weeks record, making it editable again. // Individual closed days are NOT automatically reopened. func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error { existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return ErrWeekNotClosed } return err } if existing == nil { return ErrWeekNotClosed } return s.closedWeeks.Delete(ctx, weekKey) } // ListWeeks returns closed weeks within a range. func (s *WeekService) ListWeeks(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) { return s.closedWeeks.ListByRange(ctx, fromWeekKey, toWeekKey) } // GetWeek returns a single closed week. func (s *WeekService) GetWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) { cw, err := s.closedWeeks.GetByWeekKey(ctx, weekKey) if errors.Is(err, sql.ErrNoRows) { return nil, nil } return cw, err }