feat(m2): settings history, close day, holiday/vacation/sick marking

This commit is contained in:
2026-04-30 16:37:56 +02:00
parent 3aa068efd2
commit 4a0e0c8318
7 changed files with 607 additions and 2 deletions

View File

@@ -0,0 +1,167 @@
package service
import (
"context"
"database/sql"
"errors"
"time"
"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")
ErrWeekHasUnclosedDays = errors.New("all workdays in the week must be closed before closing the week")
ErrDayHasNoEntries = errors.New("no completed entries found for this day")
)
// DayService handles closing and marking days.
type DayService struct {
entries *store.EntryStore
closedDays *store.ClosedDayStore
settings *store.SettingsStore
tz *time.Location
}
func NewDayService(
entries *store.EntryStore,
closedDays *store.ClosedDayStore,
settings *store.SettingsStore,
tz *time.Location,
) *DayService {
return &DayService{
entries: entries,
closedDays: closedDays,
settings: settings,
tz: tz,
}
}
// 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.
func (s *DayService) CloseDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) {
// Already closed?
existing, err := s.closedDays.GetByDayKey(ctx, dayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if existing != nil {
return nil, ErrDayAlreadyClosed
}
// Running entry?
running, err := s.entries.RunningEntryForDay(ctx, dayKey)
if err != nil {
return nil, err
}
if running != nil {
return nil, ErrRunningEntryOnDay
}
// Load all completed entries for the day
entries, err := s.entries.ListByDayKey(ctx, dayKey)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, ErrDayHasNoEntries
}
var minStart, maxEnd, totalMs int64
minStart = entries[0].StartTime
for _, e := range entries {
if e.EndTime == nil {
continue
}
if e.StartTime < minStart {
minStart = e.StartTime
}
if *e.EndTime > maxEnd {
maxEnd = *e.EndTime
}
totalMs += e.DurationMs()
}
now := time.Now().UnixMilli()
cd := &domain.ClosedDay{
DayKey: dayKey,
StartTime: &minStart,
EndTime: &maxEnd,
WorkedMs: totalMs,
Kind: domain.DayKindWork,
ClosedAt: now,
UpdatedAt: now,
}
if err := s.closedDays.Upsert(ctx, cd); err != nil {
return nil, err
}
return cd, nil
}
// MarkDay closes a day as holiday, vacation, or sick.
// worked_ms is set to the expected daily ms from settings at that date.
func (s *DayService) MarkDay(ctx context.Context, dayKey string, kind domain.DayKind) (*domain.ClosedDay, error) {
if !kind.Valid() || kind == domain.DayKindWork {
return nil, errors.New("kind must be one of: holiday, vacation, sick")
}
// Lookup settings effective on that day
set, err := s.settings.Current(ctx, dayKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
var workedMs int64
if set != nil {
// Parse the weekday from dayKey
t, err := time.ParseInLocation("2006-01-02", dayKey, s.tz)
if err == nil && set.IsWorkday(int(t.Weekday())) {
workedMs = set.DailyExpectedMs()
}
}
now := time.Now().UnixMilli()
cd := &domain.ClosedDay{
DayKey: dayKey,
WorkedMs: workedMs,
Kind: kind,
ClosedAt: now,
UpdatedAt: now,
}
if err := s.closedDays.Upsert(ctx, cd); err != nil {
return nil, err
}
return cd, nil
}
// ReopenDay deletes the closed_days row for a day, making it editable again.
func (s *DayService) ReopenDay(ctx context.Context, dayKey string) error {
existing, err := s.closedDays.GetByDayKey(ctx, dayKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrDayNotClosed
}
return err
}
if existing == nil {
return ErrDayNotClosed
}
return s.closedDays.Delete(ctx, dayKey)
}
// GetDay returns the closed day for a given day key, or nil if open.
func (s *DayService) GetDay(ctx context.Context, dayKey string) (*domain.ClosedDay, error) {
cd, err := s.closedDays.GetByDayKey(ctx, dayKey)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return cd, err
}
// ListDays returns closed days within a date range.
func (s *DayService) ListDays(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) {
return s.closedDays.ListByDateRange(ctx, fromDayKey, toDayKey)
}

View File

@@ -0,0 +1,162 @@
package service_test
import (
"context"
"testing"
"time"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/service"
"github.com/wotra/wotra/internal/store"
)
func newTestDayServices(t *testing.T) (*service.EntryService, *service.DayService, *service.SettingsService) {
t.Helper()
db, err := store.Open(":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db)
settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
settingsSvc := service.NewSettingsService(settingsStore)
return entrySvc, daySvc, settingsSvc
}
func TestCloseDayBasic(t *testing.T) {
ctx := context.Background()
entrySvc, daySvc, _ := newTestDayServices(t)
// Start and stop an entry
_, err := entrySvc.Start(ctx, "work")
if err != nil {
t.Fatal(err)
}
_, err = entrySvc.Stop(ctx)
if err != nil {
t.Fatal(err)
}
today := time.Now().UTC().Format("2006-01-02")
cd, err := daySvc.CloseDay(ctx, today)
if err != nil {
t.Fatalf("CloseDay: %v", err)
}
if cd.Kind != domain.DayKindWork {
t.Errorf("expected kind=work, got %s", cd.Kind)
}
if cd.WorkedMs < 0 {
t.Error("expected non-negative worked_ms")
}
}
func TestCloseDayWithRunningEntryFails(t *testing.T) {
ctx := context.Background()
entrySvc, daySvc, _ := newTestDayServices(t)
_, err := entrySvc.Start(ctx, "")
if err != nil {
t.Fatal(err)
}
today := time.Now().UTC().Format("2006-01-02")
_, err = daySvc.CloseDay(ctx, today)
if err == nil {
t.Fatal("expected error closing day with running entry")
}
if err != service.ErrRunningEntryOnDay {
t.Fatalf("expected ErrRunningEntryOnDay, got %v", err)
}
}
func TestCloseDayTwiceFails(t *testing.T) {
ctx := context.Background()
entrySvc, daySvc, _ := newTestDayServices(t)
entrySvc.Start(ctx, "")
entrySvc.Stop(ctx)
today := time.Now().UTC().Format("2006-01-02")
daySvc.CloseDay(ctx, today)
_, err := daySvc.CloseDay(ctx, today)
if err != service.ErrDayAlreadyClosed {
t.Fatalf("expected ErrDayAlreadyClosed, got %v", err)
}
}
func TestMarkDayHoliday(t *testing.T) {
ctx := context.Background()
_, daySvc, _ := newTestDayServices(t)
today := time.Now().UTC().Format("2006-01-02")
cd, err := daySvc.MarkDay(ctx, today, domain.DayKindHoliday)
if err != nil {
t.Fatalf("MarkDay: %v", err)
}
if cd.Kind != domain.DayKindHoliday {
t.Errorf("expected kind=holiday, got %s", cd.Kind)
}
// Monday-Friday = 40h/5 = 8h = 28800000ms expected
if today == time.Now().UTC().Format("2006-01-02") {
wd := int(time.Now().UTC().Weekday())
// workdays Mon-Fri (mask=31): weekdays 1-5
if wd >= 1 && wd <= 5 {
if cd.WorkedMs != 8*3600*1000 {
t.Errorf("expected 8h worked_ms for holiday on workday, got %d", cd.WorkedMs)
}
}
}
}
func TestReopenDay(t *testing.T) {
ctx := context.Background()
entrySvc, daySvc, _ := newTestDayServices(t)
entrySvc.Start(ctx, "")
entrySvc.Stop(ctx)
today := time.Now().UTC().Format("2006-01-02")
daySvc.CloseDay(ctx, today)
if err := daySvc.ReopenDay(ctx, today); err != nil {
t.Fatalf("ReopenDay: %v", err)
}
// Should be closeable again
_, err := daySvc.CloseDay(ctx, today)
if err != nil {
t.Fatalf("CloseDay after reopen: %v", err)
}
}
func TestSettingsUpsertAndHistory(t *testing.T) {
ctx := context.Background()
_, _, settingsSvc := newTestDayServices(t)
set, err := settingsSvc.Upsert(ctx, service.UpsertSettingsInput{
EffectiveFrom: "2024-01-01",
HoursPerWeek: 38.5,
WorkdaysMask: 31,
Timezone: "Europe/Berlin",
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if set.HoursPerWeek != 38.5 {
t.Errorf("expected 38.5 h/week, got %f", set.HoursPerWeek)
}
history, err := settingsSvc.History(ctx)
if err != nil {
t.Fatal(err)
}
// Seeded default + our new one
if len(history) < 2 {
t.Fatalf("expected >=2 history entries, got %d", len(history))
}
}

View File

@@ -0,0 +1,89 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/store"
)
var (
ErrNoSettings = errors.New("no settings found")
ErrInvalidWorkdaysMask = errors.New("workdays_mask must be between 1 and 127")
ErrInvalidHours = errors.New("hours_per_week must be > 0")
)
// SettingsService manages settings with effective-from history.
type SettingsService struct {
store *store.SettingsStore
}
func NewSettingsService(s *store.SettingsStore) *SettingsService {
return &SettingsService{store: s}
}
// Current returns settings effective as of today.
func (s *SettingsService) Current(ctx context.Context) (*domain.Settings, error) {
today := time.Now().UTC().Format("2006-01-02")
set, err := s.store.Current(ctx, today)
if err != nil {
return nil, ErrNoSettings
}
return set, nil
}
// AsOf returns settings effective on the given day key (YYYY-MM-DD).
func (s *SettingsService) AsOf(ctx context.Context, dayKey string) (*domain.Settings, error) {
set, err := s.store.Current(ctx, dayKey)
if err != nil {
return nil, ErrNoSettings
}
return set, nil
}
// History returns all settings rows, newest first.
func (s *SettingsService) History(ctx context.Context) ([]*domain.Settings, error) {
return s.store.History(ctx)
}
// UpsertInput is the payload for creating a new settings version.
type UpsertSettingsInput struct {
EffectiveFrom string
HoursPerWeek float64
WorkdaysMask int
Timezone string
}
// Upsert creates a new settings row (always inserts; effective_from allows retroactive changes).
func (s *SettingsService) Upsert(ctx context.Context, input UpsertSettingsInput) (*domain.Settings, error) {
if input.HoursPerWeek <= 0 {
return nil, ErrInvalidHours
}
if input.WorkdaysMask < 1 || input.WorkdaysMask > 127 {
return nil, ErrInvalidWorkdaysMask
}
if input.Timezone == "" {
input.Timezone = "UTC"
}
if _, err := time.LoadLocation(input.Timezone); err != nil {
return nil, fmt.Errorf("invalid timezone: %w", err)
}
if _, err := time.Parse("2006-01-02", input.EffectiveFrom); err != nil {
return nil, fmt.Errorf("invalid effective_from: %w", err)
}
set := &domain.Settings{
EffectiveFrom: input.EffectiveFrom,
HoursPerWeek: input.HoursPerWeek,
WorkdaysMask: input.WorkdaysMask,
Timezone: input.Timezone,
CreatedAt: time.Now().UnixMilli(),
}
if err := s.store.Insert(ctx, set); err != nil {
return nil, err
}
return set, nil
}