feat(m2): settings history, close day, holiday/vacation/sick marking
This commit is contained in:
@@ -43,13 +43,15 @@ func main() {
|
|||||||
settingsStore := store.NewSettingsStore(db)
|
settingsStore := store.NewSettingsStore(db)
|
||||||
|
|
||||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||||
|
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
|
||||||
|
settingsSvc := service.NewSettingsService(settingsStore)
|
||||||
|
|
||||||
// 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())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
go runMidnightGuard(ctx, entrySvc)
|
go runMidnightGuard(ctx, entrySvc)
|
||||||
|
|
||||||
router := handler.NewRouter(cfg.AuthToken, entrySvc)
|
router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|||||||
101
internal/handler/day_handler.go
Normal file
101
internal/handler/day_handler.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/wotra/wotra/internal/domain"
|
||||||
|
"github.com/wotra/wotra/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DayHandler serves /api/days routes.
|
||||||
|
type DayHandler struct {
|
||||||
|
svc *service.DayService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDayHandler(svc *service.DayService) *DayHandler {
|
||||||
|
return &DayHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DayHandler) Routes(r chi.Router) {
|
||||||
|
r.Get("/days", h.List)
|
||||||
|
r.Post("/days/{day_key}/close", h.Close)
|
||||||
|
r.Post("/days/{day_key}/mark", h.Mark)
|
||||||
|
r.Delete("/days/{day_key}/close", h.Reopen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /api/days?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
func (h *DayHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
from := r.URL.Query().Get("from")
|
||||||
|
to := r.URL.Query().Get("to")
|
||||||
|
if from == "" {
|
||||||
|
from = "0000-01-01"
|
||||||
|
}
|
||||||
|
if to == "" {
|
||||||
|
to = "9999-12-31"
|
||||||
|
}
|
||||||
|
days, err := h.svc.ListDays(r.Context(), from, to)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if days == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []struct{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close POST /api/days/{day_key}/close
|
||||||
|
func (h *DayHandler) Close(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dayKey := chi.URLParam(r, "day_key")
|
||||||
|
cd, err := h.svc.CloseDay(r.Context(), dayKey)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrDayAlreadyClosed):
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrRunningEntryOnDay):
|
||||||
|
writeError(w, http.StatusConflict, err.Error())
|
||||||
|
case errors.Is(err, service.ErrDayHasNoEntries):
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark POST /api/days/{day_key}/mark
|
||||||
|
func (h *DayHandler) Mark(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dayKey := chi.URLParam(r, "day_key")
|
||||||
|
var body struct {
|
||||||
|
Kind domain.DayKind `json:"kind"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cd, err := h.svc.MarkDay(r.Context(), dayKey, body.Kind)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reopen DELETE /api/days/{day_key}/close
|
||||||
|
func (h *DayHandler) Reopen(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dayKey := chi.URLParam(r, "day_key")
|
||||||
|
if err := h.svc.ReopenDay(r.Context(), dayKey); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrDayNotClosed):
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter builds the full HTTP router.
|
// NewRouter builds the full HTTP router.
|
||||||
func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler {
|
func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
@@ -27,6 +27,12 @@ func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler {
|
|||||||
|
|
||||||
entryH := NewEntryHandler(entrySvc)
|
entryH := NewEntryHandler(entrySvc)
|
||||||
entryH.Routes(r)
|
entryH.Routes(r)
|
||||||
|
|
||||||
|
dayH := NewDayHandler(daySvc)
|
||||||
|
dayH.Routes(r)
|
||||||
|
|
||||||
|
settingsH := NewSettingsHandler(settingsSvc)
|
||||||
|
settingsH.Routes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
78
internal/handler/settings_handler.go
Normal file
78
internal/handler/settings_handler.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/wotra/wotra/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingsHandler serves /api/settings routes.
|
||||||
|
type SettingsHandler struct {
|
||||||
|
svc *service.SettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler {
|
||||||
|
return &SettingsHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) Routes(r chi.Router) {
|
||||||
|
r.Get("/settings", h.Current)
|
||||||
|
r.Put("/settings", h.Upsert)
|
||||||
|
r.Get("/settings/history", h.History)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current GET /api/settings
|
||||||
|
func (h *SettingsHandler) Current(w http.ResponseWriter, r *http.Request) {
|
||||||
|
set, err := h.svc.Current(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert PUT /api/settings
|
||||||
|
func (h *SettingsHandler) Upsert(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
EffectiveFrom string `json:"effective_from"`
|
||||||
|
HoursPerWeek float64 `json:"hours_per_week"`
|
||||||
|
WorkdaysMask int `json:"workdays_mask"`
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set, err := h.svc.Upsert(r.Context(), service.UpsertSettingsInput{
|
||||||
|
EffectiveFrom: body.EffectiveFrom,
|
||||||
|
HoursPerWeek: body.HoursPerWeek,
|
||||||
|
WorkdaysMask: body.WorkdaysMask,
|
||||||
|
Timezone: body.Timezone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrInvalidHours), errors.Is(err, service.ErrInvalidWorkdaysMask):
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, set)
|
||||||
|
}
|
||||||
|
|
||||||
|
// History GET /api/settings/history
|
||||||
|
func (h *SettingsHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||||
|
history, err := h.svc.History(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if history == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []struct{}{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, history)
|
||||||
|
}
|
||||||
167
internal/service/day_service.go
Normal file
167
internal/service/day_service.go
Normal 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)
|
||||||
|
}
|
||||||
162
internal/service/day_service_test.go
Normal file
162
internal/service/day_service_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
89
internal/service/settings_service.go
Normal file
89
internal/service/settings_service.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user