fix: skip untracked workdays when closing a week

Previously any past workday without a closed_days record blocked week
close. Now only days that actually have entries require an explicit
close. Empty workdays count as 0h worked, which is reflected in the
weekly delta automatically.

- WeekService.CloseWeek: after finding no closed_days record, check
  whether the day has any entries; only error if it does
- NewWeekService: takes EntryStore to support the above check
- Updated TestCloseWeekMissingDayFails to reflect the new semantic
  (test now creates entries on Friday but leaves it unclosed)
This commit is contained in:
2026-04-30 17:59:04 +02:00
parent 6fceda46b5
commit c675a7b01d
3 changed files with 39 additions and 12 deletions

View File

@@ -48,7 +48,7 @@ func main() {
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
settingsSvc := service.NewSettingsService(settingsStore) settingsSvc := service.NewSettingsService(settingsStore)
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz)
// Background goroutine: auto-stop entries that cross midnight // Background goroutine: auto-stop entries that cross midnight
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())

View File

@@ -15,6 +15,7 @@ import (
type WeekService struct { type WeekService struct {
closedDays *store.ClosedDayStore closedDays *store.ClosedDayStore
closedWeeks *store.ClosedWeekStore closedWeeks *store.ClosedWeekStore
entries *store.EntryStore
settings *store.SettingsStore settings *store.SettingsStore
db interface { db interface {
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
@@ -26,6 +27,7 @@ type WeekService struct {
func NewWeekService( func NewWeekService(
closedDays *store.ClosedDayStore, closedDays *store.ClosedDayStore,
closedWeeks *store.ClosedWeekStore, closedWeeks *store.ClosedWeekStore,
entries *store.EntryStore,
settings *store.SettingsStore, settings *store.SettingsStore,
rawDB *sql.DB, rawDB *sql.DB,
tz *time.Location, tz *time.Location,
@@ -33,6 +35,7 @@ func NewWeekService(
return &WeekService{ return &WeekService{
closedDays: closedDays, closedDays: closedDays,
closedWeeks: closedWeeks, closedWeeks: closedWeeks,
entries: entries,
settings: settings, settings: settings,
rawDB: rawDB, rawDB: rawDB,
tz: tz, tz: tz,
@@ -88,8 +91,9 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl
// Compute expected ms for the week (from settings frozen at week start) // Compute expected ms for the week (from settings frozen at week start)
expectedMs := int64(set.HoursPerWeek * 3_600_000) expectedMs := int64(set.HoursPerWeek * 3_600_000)
// Verify all workdays up to and including today are closed; collect worked ms. // Verify all past workdays that have entries are closed; collect worked ms.
// Future workdays in the week (not yet reached) are skipped. // 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") todayKey := time.Now().In(s.tz).Format("2006-01-02")
var totalWorkedMs int64 var totalWorkedMs int64
for _, dk := range dayKeys { for _, dk := range dayKeys {
@@ -101,13 +105,23 @@ func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.Cl
continue // future workday — skip continue // future workday — skip
} }
cd, err := s.closedDays.GetByDayKey(ctx, dk) cd, err := s.closedDays.GetByDayKey(ctx, dk)
if err != nil { if err != nil && !errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk)
}
return nil, err return nil, err
} }
totalWorkedMs += cd.WorkedMs 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() now := time.Now().UnixMilli()

View File

@@ -27,7 +27,7 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz) weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz)
settingsSvc := service.NewSettingsService(settingsStore) settingsSvc := service.NewSettingsService(settingsStore)
return entrySvc, daySvc, weekSvc, settingsSvc return entrySvc, daySvc, weekSvc, settingsSvc
} }
@@ -88,19 +88,32 @@ func TestCloseWeekBasic(t *testing.T) {
} }
func TestCloseWeekMissingDayFails(t *testing.T) { func TestCloseWeekMissingDayFails(t *testing.T) {
// A past workday that HAS entries but was never closed should still block week close.
ctx := context.Background() ctx := context.Background()
_, daySvc, weekSvc, _ := newFullServices(t) entrySvc, daySvc, weekSvc, _ := newFullServices(t)
// Only close Mon-Thu, leave Friday open — all are in the past // Use a fixed past week: 2024-W03 (Mon 2024-01-15 .. Sun 2024-01-21)
monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
// Close MonThu as holiday.
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
dk := monday.AddDate(0, 0, i).Format("2006-01-02") dk := monday.AddDate(0, 0, i).Format("2006-01-02")
daySvc.MarkDay(ctx, dk, domain.DayKindHoliday) daySvc.MarkDay(ctx, dk, domain.DayKindHoliday)
} }
// Friday (2024-01-19): add a completed entry but do NOT close the day.
fridayStart := time.Date(2024, 1, 19, 9, 0, 0, 0, time.UTC).UnixMilli()
fridayEnd := time.Date(2024, 1, 19, 17, 0, 0, 0, time.UTC).UnixMilli()
if _, err := entrySvc.CreateInterval(ctx, service.CreateIntervalInput{
StartTime: fridayStart,
EndTime: fridayEnd,
}); err != nil {
t.Fatalf("CreateInterval: %v", err)
}
_, err := weekSvc.CloseWeek(ctx, "2024-W03") _, err := weekSvc.CloseWeek(ctx, "2024-W03")
if err == nil { if err == nil {
t.Fatal("expected error closing week with unclosed past day") t.Fatal("expected error: friday has entries but is not closed")
} }
} }