package service import ( "context" "database/sql" "errors" "fmt" "time" "github.com/wotra/wotra/internal/domain" "github.com/wotra/wotra/internal/store" ) // Sentinel errors for balance adjustments. var ( ErrZeroAdjustment = errors.New("delta_ms must be non-zero") ErrAdjustmentNotFound = errors.New("balance adjustment not found") ) // WeekService handles closing weeks and computing overtime/undertime. type WeekService struct { closedDays *store.ClosedDayStore closedWeeks *store.ClosedWeekStore adjustments *store.BalanceAdjustmentStore syncStore *store.SyncStore 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, adjustments *store.BalanceAdjustmentStore, syncStore *store.SyncStore, rawDB *sql.DB, tz *time.Location, ) *WeekService { return &WeekService{ closedDays: closedDays, closedWeeks: closedWeeks, adjustments: adjustments, syncStore: syncStore, entries: entries, settings: settings, rawDB: rawDB, tz: tz, } } // BalanceResult holds the overall overtime/undertime balance across all closed weeks. type BalanceResult struct { TotalDeltaMs int64 `json:"total_delta_ms"` WeeksDeltaMs int64 `json:"weeks_delta_ms"` AdjustmentsDeltaMs int64 `json:"adjustments_delta_ms"` ClosedWeekCount int `json:"closed_week_count"` AdjustmentCount int `json:"adjustment_count"` } // Balance returns the combined overtime balance: closed weeks + manual adjustments. func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) { weeksTotal, weekCount, err := s.closedWeeks.SumDelta(ctx) if err != nil { return BalanceResult{}, err } adjTotal, adjCount, err := s.adjustments.SumDelta(ctx) if err != nil { return BalanceResult{}, err } return BalanceResult{ TotalDeltaMs: weeksTotal + adjTotal, WeeksDeltaMs: weeksTotal, AdjustmentsDeltaMs: adjTotal, ClosedWeekCount: weekCount, AdjustmentCount: adjCount, }, nil } // ListAdjustments returns all balance adjustments ordered by effective_at DESC. func (s *WeekService) ListAdjustments(ctx context.Context) ([]*domain.BalanceAdjustment, error) { return s.adjustments.List(ctx) } // CreateAdjustment creates and sync-logs a new balance adjustment. func (s *WeekService) CreateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) { if a.DeltaMs == 0 { return nil, ErrZeroAdjustment } if err := s.adjustments.Create(ctx, a); err != nil { return nil, err } _ = s.syncStore.LogBalanceAdjustment(ctx, a) // best-effort return a, nil } // UpdateAdjustment updates and sync-logs an existing balance adjustment. func (s *WeekService) UpdateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) { if a.DeltaMs == 0 { return nil, ErrZeroAdjustment } if err := s.adjustments.Update(ctx, a); err != nil { if errors.Is(err, store.ErrAdjustmentNotFound) { return nil, ErrAdjustmentNotFound } return nil, err } got, err := s.adjustments.GetByID(ctx, a.ID) if err != nil { return nil, err } _ = s.syncStore.LogBalanceAdjustment(ctx, got) // best-effort return got, nil } // DeleteAdjustment deletes and sync-logs a balance adjustment. func (s *WeekService) DeleteAdjustment(ctx context.Context, id string) error { if err := s.adjustments.Delete(ctx, id); err != nil { if errors.Is(err, store.ErrAdjustmentNotFound) { return ErrAdjustmentNotFound } return err } _ = s.syncStore.LogBalanceAdjustmentDelete(ctx, id) // best-effort return nil } // WeekDayKeysExported is exported for testing. var WeekDayKeysExported = weekDayKeys // WeekKeyForDayKey returns the ISO week key (YYYY-Www) for a given YYYY-MM-DD day key. func WeekKeyForDayKey(dayKey string, tz *time.Location) (string, error) { t, err := time.ParseInLocation("2006-01-02", dayKey, tz) if err != nil { return "", fmt.Errorf("invalid day_key %q: %w", dayKey, err) } year, week := t.ISOWeek() return fmt.Sprintf("%d-W%02d", year, week), nil } // 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) // (weekday+6)%7 gives days-since-Monday (Mon=0 … Sun=6), avoiding the // sign issue when Weekday()==0 (Sunday) with the naive subtraction. daysSinceMonday := int(jan4.Weekday()+6) % 7 _, jan4Week := jan4.ISOWeek() monday := jan4.AddDate(0, 0, -daysSinceMonday+(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 effective at close time (today), not necessarily at the // start of the week. This ensures settings changes made mid-week are // reflected when the week is closed. set, err := s.settings.Current(ctx, time.Now().In(s.tz).Format("2006-01-02")) 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 } _ = s.syncStore.LogClosedWeek(ctx, cw) 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 } if err := s.closedWeeks.Delete(ctx, weekKey); err != nil { return err } _ = s.syncStore.LogClosedWeekDelete(ctx, weekKey) return nil } // 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 }