Files
wotra/internal/service/week_service.go
Andreas Schneider 47dd2c9779 fix: CloseWeek uses settings effective at close time, not week Monday
Settings configured mid-week (e.g. Thursday) have an effective_from
of that date. CloseWeek was looking up settings as-of Monday, which
predates the new settings row and fell back to the old default.

Now uses today's date for the settings lookup, so any settings change
made before closing the week is correctly reflected in expected_ms.
2026-04-30 18:23:13 +02:00

185 lines
5.5 KiB
Go

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
// 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
}
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
}