feat(m3): week close, overtime/undertime delta, frozen settings snapshot
This commit is contained in:
@@ -40,18 +40,20 @@ func main() {
|
||||
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
closedWeekStore := store.NewClosedWeekStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
|
||||
settingsSvc := service.NewSettingsService(settingsStore)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz)
|
||||
|
||||
// Background goroutine: auto-stop entries that cross midnight
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go runMidnightGuard(ctx, entrySvc)
|
||||
|
||||
router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc)
|
||||
router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc, weekSvc)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// NewRouter builds the full HTTP router.
|
||||
func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService) http.Handler {
|
||||
func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService, weekSvc *service.WeekService) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
@@ -33,6 +33,9 @@ func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service
|
||||
|
||||
settingsH := NewSettingsHandler(settingsSvc)
|
||||
settingsH.Routes(r)
|
||||
|
||||
weekH := NewWeekHandler(weekSvc)
|
||||
weekH.Routes(r)
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
81
internal/handler/week_handler.go
Normal file
81
internal/handler/week_handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
)
|
||||
|
||||
// WeekHandler serves /api/weeks routes.
|
||||
type WeekHandler struct {
|
||||
svc *service.WeekService
|
||||
}
|
||||
|
||||
func NewWeekHandler(svc *service.WeekService) *WeekHandler {
|
||||
return &WeekHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *WeekHandler) Routes(r chi.Router) {
|
||||
r.Get("/weeks", h.List)
|
||||
r.Post("/weeks/{week_key}/close", h.Close)
|
||||
r.Delete("/weeks/{week_key}/close", h.Reopen)
|
||||
}
|
||||
|
||||
// List GET /api/weeks?from=YYYY-Www&to=YYYY-Www
|
||||
func (h *WeekHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
from := r.URL.Query().Get("from")
|
||||
to := r.URL.Query().Get("to")
|
||||
if from == "" {
|
||||
from = "0000-W01"
|
||||
}
|
||||
if to == "" {
|
||||
to = "9999-W53"
|
||||
}
|
||||
weeks, err := h.svc.ListWeeks(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if weeks == nil {
|
||||
writeJSON(w, http.StatusOK, []struct{}{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, weeks)
|
||||
}
|
||||
|
||||
// Close POST /api/weeks/{week_key}/close
|
||||
func (h *WeekHandler) Close(w http.ResponseWriter, r *http.Request) {
|
||||
weekKey := chi.URLParam(r, "week_key")
|
||||
cw, err := h.svc.CloseWeek(r.Context(), weekKey)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrWeekAlreadyClosed):
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, service.ErrWeekHasUnclosedDays):
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
case errors.Is(err, service.ErrNoSettings):
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cw)
|
||||
}
|
||||
|
||||
// Reopen DELETE /api/weeks/{week_key}/close
|
||||
func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) {
|
||||
weekKey := chi.URLParam(r, "week_key")
|
||||
if err := h.svc.ReopenWeek(r.Context(), weekKey); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrWeekNotClosed):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
151
internal/service/week_service.go
Normal file
151
internal/service/week_service.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
// WeekService handles closing weeks and computing overtime/undertime.
|
||||
type WeekService struct {
|
||||
closedDays *store.ClosedDayStore
|
||||
closedWeeks *store.ClosedWeekStore
|
||||
settings *store.SettingsStore
|
||||
db interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
||||
}
|
||||
rawDB *sql.DB
|
||||
tz *time.Location
|
||||
}
|
||||
|
||||
func NewWeekService(
|
||||
closedDays *store.ClosedDayStore,
|
||||
closedWeeks *store.ClosedWeekStore,
|
||||
settings *store.SettingsStore,
|
||||
rawDB *sql.DB,
|
||||
tz *time.Location,
|
||||
) *WeekService {
|
||||
return &WeekService{
|
||||
closedDays: closedDays,
|
||||
closedWeeks: closedWeeks,
|
||||
settings: settings,
|
||||
rawDB: rawDB,
|
||||
tz: tz,
|
||||
}
|
||||
}
|
||||
|
||||
// WeekDayKeysExported is exported for testing.
|
||||
var WeekDayKeysExported = weekDayKeys
|
||||
|
||||
// weekDayKeys returns the YYYY-MM-DD keys for Mon-Sun of the ISO week encoded as weekKey.
|
||||
// weekKey format: "YYYY-Www" (e.g. "2024-W03").
|
||||
func weekDayKeys(weekKey string, tz *time.Location) ([]string, error) {
|
||||
var year, week int
|
||||
if _, err := fmt.Sscanf(weekKey, "%d-W%02d", &year, &week); err != nil {
|
||||
return nil, fmt.Errorf("invalid week_key %q: expected YYYY-Www", weekKey)
|
||||
}
|
||||
// Find the Monday of that ISO week.
|
||||
// Jan 4 is always in week 1 of its year.
|
||||
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, tz)
|
||||
_, jan4Week := jan4.ISOWeek()
|
||||
monday := jan4.AddDate(0, 0, -int(jan4.Weekday()-time.Monday)+(week-jan4Week)*7)
|
||||
|
||||
keys := make([]string, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
keys[i] = monday.AddDate(0, 0, i).Format("2006-01-02")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// CloseWeek closes an ISO week. All workdays must already be closed.
|
||||
func (s *WeekService) CloseWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
||||
// Already closed?
|
||||
existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, ErrWeekAlreadyClosed
|
||||
}
|
||||
|
||||
dayKeys, err := weekDayKeys(weekKey, s.tz)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get settings at the start of the week (Monday)
|
||||
mondayKey := dayKeys[0]
|
||||
set, err := s.settings.Current(ctx, mondayKey)
|
||||
if err != nil {
|
||||
return nil, ErrNoSettings
|
||||
}
|
||||
|
||||
// Compute expected ms for the week (from settings frozen at week start)
|
||||
expectedMs := int64(set.HoursPerWeek * 3_600_000)
|
||||
|
||||
// Verify all workdays are closed; collect worked ms
|
||||
var totalWorkedMs int64
|
||||
for _, dk := range dayKeys {
|
||||
t, _ := time.ParseInLocation("2006-01-02", dk, s.tz)
|
||||
if !set.IsWorkday(int(t.Weekday())) {
|
||||
continue // weekend or non-workday — skip
|
||||
}
|
||||
cd, err := s.closedDays.GetByDayKey(ctx, dk)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
totalWorkedMs += cd.WorkedMs
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
cw := &domain.ClosedWeek{
|
||||
WeekKey: weekKey,
|
||||
ExpectedMs: expectedMs,
|
||||
WorkedMs: totalWorkedMs,
|
||||
DeltaMs: totalWorkedMs - expectedMs,
|
||||
ClosedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.closedWeeks.Upsert(ctx, cw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cw, nil
|
||||
}
|
||||
|
||||
// ReopenWeek deletes the closed_weeks record, making it editable again.
|
||||
// Individual closed days are NOT automatically reopened.
|
||||
func (s *WeekService) ReopenWeek(ctx context.Context, weekKey string) error {
|
||||
existing, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrWeekNotClosed
|
||||
}
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return ErrWeekNotClosed
|
||||
}
|
||||
return s.closedWeeks.Delete(ctx, weekKey)
|
||||
}
|
||||
|
||||
// ListWeeks returns closed weeks within a range.
|
||||
func (s *WeekService) ListWeeks(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) {
|
||||
return s.closedWeeks.ListByRange(ctx, fromWeekKey, toWeekKey)
|
||||
}
|
||||
|
||||
// GetWeek returns a single closed week.
|
||||
func (s *WeekService) GetWeek(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) {
|
||||
cw, err := s.closedWeeks.GetByWeekKey(ctx, weekKey)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return cw, err
|
||||
}
|
||||
144
internal/service/week_service_test.go
Normal file
144
internal/service/week_service_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func newFullServices(t *testing.T) (*service.EntryService, *service.DayService, *service.WeekService, *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)
|
||||
closedWeekStore := store.NewClosedWeekStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
daySvc := service.NewDayService(entryStore, closedDayStore, settingsStore, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, settingsStore, db, tz)
|
||||
settingsSvc := service.NewSettingsService(settingsStore)
|
||||
return entrySvc, daySvc, weekSvc, settingsSvc
|
||||
}
|
||||
|
||||
func TestWeekDayKeys(t *testing.T) {
|
||||
// 2024-W03 = Jan 15-21, 2024
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
keys, err := service.WeekDayKeysExported("2024-W03", tz)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 7 {
|
||||
t.Fatalf("expected 7 keys, got %d", len(keys))
|
||||
}
|
||||
if keys[0] != "2024-01-15" {
|
||||
t.Errorf("expected Monday 2024-01-15, got %s", keys[0])
|
||||
}
|
||||
if keys[6] != "2024-01-21" {
|
||||
t.Errorf("expected Sunday 2024-01-21, got %s", keys[6])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseWeekBasic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
entrySvc, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
// Find the ISO week for "this week" using a known Mon-Fri
|
||||
// Use a fixed Monday to make the test deterministic
|
||||
monday := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) // 2024-W03, Monday
|
||||
weekKey := "2024-W03"
|
||||
|
||||
// Close Mon-Fri by marking as holiday (easiest — no entries needed)
|
||||
for i := 0; i < 5; i++ {
|
||||
dk := monday.AddDate(0, 0, i).Format("2006-01-02")
|
||||
_, err := daySvc.MarkDay(ctx, dk, domain.DayKindHoliday)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkDay %s: %v", dk, err)
|
||||
}
|
||||
}
|
||||
|
||||
cw, err := weekSvc.CloseWeek(ctx, weekKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CloseWeek: %v", err)
|
||||
}
|
||||
// Expected: 40h = 144000000 ms
|
||||
if cw.ExpectedMs != 40*3_600_000 {
|
||||
t.Errorf("expected 40h expected_ms, got %d ms", cw.ExpectedMs)
|
||||
}
|
||||
// Worked: each holiday day = 8h = 5 * 8h = 40h
|
||||
if cw.WorkedMs != 40*3_600_000 {
|
||||
t.Errorf("expected 40h worked_ms, got %d ms", cw.WorkedMs)
|
||||
}
|
||||
if cw.DeltaMs != 0 {
|
||||
t.Errorf("expected 0 delta_ms, got %d", cw.DeltaMs)
|
||||
}
|
||||
fmt.Printf("WeekKey=%s Expected=%dh Worked=%dh Delta=%dms\n",
|
||||
cw.WeekKey, cw.ExpectedMs/3_600_000, cw.WorkedMs/3_600_000, cw.DeltaMs)
|
||||
_ = entrySvc
|
||||
}
|
||||
|
||||
func TestCloseWeekMissingDayFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
// Only close Mon-Thu, leave Friday open
|
||||
monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 4; i++ {
|
||||
dk := monday.AddDate(0, 0, i).Format("2006-01-02")
|
||||
daySvc.MarkDay(ctx, dk, domain.DayKindHoliday)
|
||||
}
|
||||
|
||||
_, err := weekSvc.CloseWeek(ctx, "2024-W03")
|
||||
if err == nil {
|
||||
t.Fatal("expected error closing week with unclosed day")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseWeekTwiceFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 5; i++ {
|
||||
dk := monday.AddDate(0, 0, i).Format("2006-01-02")
|
||||
daySvc.MarkDay(ctx, dk, domain.DayKindHoliday)
|
||||
}
|
||||
weekSvc.CloseWeek(ctx, "2024-W03")
|
||||
|
||||
_, err := weekSvc.CloseWeek(ctx, "2024-W03")
|
||||
if err != service.ErrWeekAlreadyClosed {
|
||||
t.Fatalf("expected ErrWeekAlreadyClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenWeek(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
monday := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 5; i++ {
|
||||
dk := monday.AddDate(0, 0, i).Format("2006-01-02")
|
||||
daySvc.MarkDay(ctx, dk, domain.DayKindHoliday)
|
||||
}
|
||||
weekSvc.CloseWeek(ctx, "2024-W03")
|
||||
|
||||
if err := weekSvc.ReopenWeek(ctx, "2024-W03"); err != nil {
|
||||
t.Fatalf("ReopenWeek: %v", err)
|
||||
}
|
||||
// Should be closeable again
|
||||
_, err := weekSvc.CloseWeek(ctx, "2024-W03")
|
||||
if err != nil {
|
||||
t.Fatalf("CloseWeek after reopen: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user