- Add LogClosedDayDelete and LogClosedWeekDelete to SyncStore - Inject syncStore into EntryService; log Start, Stop, StopByID, Update, CreateInterval, Delete, AutoStopStalledEntries - Inject syncStore into DayService; log CloseDay, MarkDay, ReopenDay, and the recomputeWeek closed-week upsert - Inject syncStore into SettingsService; log Upsert, UpdateSettings, DeleteSettings - Add LogClosedWeek/LogClosedWeekDelete calls in WeekService.CloseWeek and ReopenWeek - Update main.go and all service test helpers for new constructor signatures - All Go tests and 19 Vitest tests pass
225 lines
5.8 KiB
Go
225 lines
5.8 KiB
Go
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
|
|
closedWeeks *store.ClosedWeekStore
|
|
settings *store.SettingsStore
|
|
syncStore *store.SyncStore
|
|
tz *time.Location
|
|
}
|
|
|
|
func NewDayService(
|
|
entries *store.EntryStore,
|
|
closedDays *store.ClosedDayStore,
|
|
closedWeeks *store.ClosedWeekStore,
|
|
settings *store.SettingsStore,
|
|
syncStore *store.SyncStore,
|
|
tz *time.Location,
|
|
) *DayService {
|
|
return &DayService{
|
|
entries: entries,
|
|
closedDays: closedDays,
|
|
closedWeeks: closedWeeks,
|
|
settings: settings,
|
|
syncStore: syncStore,
|
|
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()
|
|
if err := s.closedWeeks.Upsert(ctx, cw); err != nil {
|
|
return err
|
|
}
|
|
_ = s.syncStore.LogClosedWeek(ctx, cw)
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
_ = s.syncStore.LogClosedDay(ctx, cd)
|
|
if err := s.recomputeWeek(ctx, dayKey); 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
|
|
}
|
|
_ = s.syncStore.LogClosedDay(ctx, cd)
|
|
if err := s.recomputeWeek(ctx, dayKey); 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
|
|
}
|
|
if err := s.closedDays.Delete(ctx, dayKey); err != nil {
|
|
return err
|
|
}
|
|
_ = s.syncStore.LogClosedDayDelete(ctx, dayKey)
|
|
return s.recomputeWeek(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)
|
|
}
|