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.
185 lines
5.5 KiB
Go
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
|
|
}
|