fix: keep closed week snapshot in sync when days change

When a day is closed, re-closed, or reopened, DayService now
recomputes worked_ms and delta_ms on the closed week containing
that day (if the week is already closed). This prevents stale
delta values after editing entries and re-closing a day.

- DayService.recomputeWeek: sums worked_ms from all closed_days
  in the week, updates closed_weeks row preserving expected_ms
- NewDayService now takes ClosedWeekStore
- WeekKeyForDayKey exported helper (used by DayService)
- TestWeekSnapshotUpdatesWhenDayReopened regression test
This commit is contained in:
2026-04-30 18:16:22 +02:00
parent 78c2c7c8a5
commit 47c7a97d47
6 changed files with 132 additions and 28 deletions

View File

@@ -9,7 +9,6 @@ import (
"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")
@@ -19,26 +18,65 @@ var (
// DayService handles closing and marking days.
type DayService struct {
entries *store.EntryStore
closedDays *store.ClosedDayStore
settings *store.SettingsStore
tz *time.Location
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,
settings: settings,
tz: tz,
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.
@@ -98,6 +136,9 @@ func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.Close
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
}
@@ -134,6 +175,9 @@ func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.Day
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
}
@@ -149,7 +193,10 @@ func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error {
if existing == nil {
return ErrDayNotClosed
}
return s.closedDays.Delete(ctx, dayKey)
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.