diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aab622b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +wotra.db diff --git a/cmd/wotra/main.go b/cmd/wotra/main.go new file mode 100644 index 0000000..6a6fd3f --- /dev/null +++ b/cmd/wotra/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/wotra/wotra/internal/config" + "github.com/wotra/wotra/internal/handler" + "github.com/wotra/wotra/internal/service" + "github.com/wotra/wotra/internal/store" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + cfg, err := config.Load() + if err != nil { + slog.Error("config error", "err", err) + os.Exit(1) + } + + tz, err := time.LoadLocation(cfg.Timezone) + if err != nil { + slog.Error("invalid timezone", "tz", cfg.Timezone, "err", err) + os.Exit(1) + } + + db, err := store.Open(cfg.DBPath) + if err != nil { + slog.Error("database error", "err", err) + os.Exit(1) + } + defer db.Close() + + entryStore := store.NewEntryStore(db) + closedDayStore := store.NewClosedDayStore(db) + settingsStore := store.NewSettingsStore(db) + + entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, 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) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + slog.Info("starting server", "port", cfg.Port, "db", cfg.DBPath, "tz", cfg.Timezone) + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + <-quit + slog.Info("shutting down") + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown error", "err", err) + } +} + +// runMidnightGuard checks every minute whether any running entries span into a new day +// and auto-stops them. +func runMidnightGuard(ctx context.Context, svc *service.EntryService) { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + ids, err := svc.AutoStopStalledEntries(ctx) + if err != nil { + slog.Error("midnight guard error", "err", err) + continue + } + if len(ids) > 0 { + slog.Info("auto-stopped entries crossing midnight", "ids", ids) + } + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1ce3dff --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/wotra/wotra + +go 1.26.2 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2536146 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d832214 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "fmt" + "os" +) + +// Config holds runtime configuration loaded from environment variables. +type Config struct { + AuthToken string + Port string + DBPath string + Timezone string +} + +// Load reads configuration from environment variables, returning an error if +// required variables are missing. +func Load() (*Config, error) { + token := os.Getenv("AUTH_TOKEN") + if token == "" { + return nil, fmt.Errorf("AUTH_TOKEN environment variable is required") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "wotra.db" + } + + tz := os.Getenv("TZ") + if tz == "" { + tz = "UTC" + } + + return &Config{ + AuthToken: token, + Port: port, + DBPath: dbPath, + Timezone: tz, + }, nil +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..5693faf --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,119 @@ +package domain + +// DayKind represents the kind of a closed day. +type DayKind string + +const ( + DayKindWork DayKind = "work" + DayKindHoliday DayKind = "holiday" + DayKindVacation DayKind = "vacation" + DayKindSick DayKind = "sick" +) + +func (k DayKind) Valid() bool { + switch k { + case DayKindWork, DayKindHoliday, DayKindVacation, DayKindSick: + return true + } + return false +} + +// Entry is a tracked time range within a single day. +type Entry struct { + ID string `json:"id"` + StartTime int64 `json:"start_time"` // unix ms UTC + EndTime *int64 `json:"end_time"` // nil while running + AutoStopped bool `json:"auto_stopped"` + Note string `json:"note"` + DayKey string `json:"day_key"` // YYYY-MM-DD in configured tz + UpdatedAt int64 `json:"updated_at"` + DeletedAt *int64 `json:"deleted_at,omitempty"` +} + +// IsRunning returns true if the entry has no end time. +func (e *Entry) IsRunning() bool { + return e.EndTime == nil +} + +// DurationMs returns the duration in milliseconds. Returns 0 for running entries. +func (e *Entry) DurationMs() int64 { + if e.EndTime == nil { + return 0 + } + return *e.EndTime - e.StartTime +} + +// ClosedDay is the merged result of closing a day. +type ClosedDay struct { + DayKey string `json:"day_key"` + StartTime *int64 `json:"start_time"` // nil for non-work kinds + EndTime *int64 `json:"end_time"` // nil for non-work kinds + WorkedMs int64 `json:"worked_ms"` + Kind DayKind `json:"kind"` + ClosedAt int64 `json:"closed_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// ClosedWeek is the overtime/undertime snapshot for a week. +type ClosedWeek struct { + WeekKey string `json:"week_key"` // YYYY-Www ISO week + ExpectedMs int64 `json:"expected_ms"` + WorkedMs int64 `json:"worked_ms"` + DeltaMs int64 `json:"delta_ms"` // worked - expected (signed) + ClosedAt int64 `json:"closed_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// Settings holds the effective configuration for a period. +type Settings struct { + ID int64 `json:"id"` + EffectiveFrom string `json:"effective_from"` // YYYY-MM-DD + HoursPerWeek float64 `json:"hours_per_week"` + WorkdaysMask int `json:"workdays_mask"` // bits Mon=1..Sun=64 + Timezone string `json:"timezone"` + CreatedAt int64 `json:"created_at"` +} + +// DailyExpectedMs returns the expected milliseconds for a single workday. +func (s *Settings) DailyExpectedMs() int64 { + days := popcount(s.WorkdaysMask) + if days == 0 { + return 0 + } + totalMs := int64(s.HoursPerWeek * 3_600_000) + return totalMs / int64(days) +} + +// IsWorkday returns true if the given weekday (0=Sunday..6=Saturday, time.Weekday) is a workday. +// We use Mon=bit0 .. Sun=bit6 internally. +func (s *Settings) IsWorkday(wd int) bool { + // time.Weekday: Sunday=0, Monday=1, ..., Saturday=6 + // our mask: Monday=bit0(1), Tuesday=bit1(2), ... Sunday=bit6(64) + var bit int + switch wd { + case 0: // Sunday + bit = 64 + case 1: // Monday + bit = 1 + case 2: + bit = 2 + case 3: + bit = 4 + case 4: + bit = 8 + case 5: + bit = 16 + case 6: // Saturday + bit = 32 + } + return s.WorkdaysMask&bit != 0 +} + +func popcount(n int) int { + count := 0 + for n != 0 { + count += n & 1 + n >>= 1 + } + return count +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..34fe254 --- /dev/null +++ b/internal/handler/auth.go @@ -0,0 +1,24 @@ +package handler + +import ( + "net/http" +) + +// AuthMiddleware returns a middleware that validates the Bearer token. +func AuthMiddleware(token string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + const prefix = "Bearer " + auth := r.Header.Get("Authorization") + if len(auth) < len(prefix) || auth[:len(prefix)] != prefix { + writeError(w, http.StatusUnauthorized, "missing or malformed Authorization header") + return + } + if auth[len(prefix):] != token { + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/handler/entry_handler.go b/internal/handler/entry_handler.go new file mode 100644 index 0000000..f414a19 --- /dev/null +++ b/internal/handler/entry_handler.go @@ -0,0 +1,129 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wotra/wotra/internal/service" +) + +// EntryHandler serves /api/entries routes. +type EntryHandler struct { + svc *service.EntryService +} + +func NewEntryHandler(svc *service.EntryService) *EntryHandler { + return &EntryHandler{svc: svc} +} + +func (h *EntryHandler) Routes(r chi.Router) { + r.Post("/entries/start", h.Start) + r.Post("/entries/{id}/stop", h.StopByID) + r.Get("/entries", h.List) + r.Put("/entries/{id}", h.Update) + r.Delete("/entries/{id}", h.Delete) +} + +// Start POST /api/entries/start +func (h *EntryHandler) Start(w http.ResponseWriter, r *http.Request) { + var body struct { + Note string `json:"note"` + } + _ = decodeJSON(r, &body) // note is optional + + entry, err := h.svc.Start(r.Context(), body.Note) + if err != nil { + switch { + case errors.Is(err, service.ErrEntryRunning): + writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, service.ErrDayAlreadyClosed): + writeError(w, http.StatusConflict, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusCreated, entry) +} + +// StopByID POST /api/entries/{id}/stop +func (h *EntryHandler) StopByID(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + entry, err := h.svc.StopByID(r.Context(), id) + if err != nil { + switch { + case errors.Is(err, service.ErrEntryNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrEntryNotRunning): + writeError(w, http.StatusConflict, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, entry) +} + +// List GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD +func (h *EntryHandler) 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" + } + entries, err := h.svc.List(r.Context(), from, to) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if entries == nil { + writeJSON(w, http.StatusOK, []struct{}{}) + return + } + writeJSON(w, http.StatusOK, entries) +} + +// Update PUT /api/entries/{id} +func (h *EntryHandler) Update(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var body struct { + StartTime *int64 `json:"start_time"` + EndTime *int64 `json:"end_time"` + Note *string `json:"note"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + entry, err := h.svc.Update(r.Context(), id, service.UpdateEntryInput{ + StartTime: body.StartTime, + EndTime: body.EndTime, + Note: body.Note, + }) + if err != nil { + switch { + case errors.Is(err, service.ErrEntryNotFound): + writeError(w, http.StatusNotFound, err.Error()) + case errors.Is(err, service.ErrCrossesMidnight): + writeError(w, http.StatusUnprocessableEntity, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } + return + } + writeJSON(w, http.StatusOK, entry) +} + +// Delete DELETE /api/entries/{id} +func (h *EntryHandler) Delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if err := h.svc.Delete(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..b2ec908 --- /dev/null +++ b/internal/handler/helpers.go @@ -0,0 +1,20 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} diff --git a/internal/handler/router.go b/internal/handler/router.go new file mode 100644 index 0000000..200c7fd --- /dev/null +++ b/internal/handler/router.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/wotra/wotra/internal/service" +) + +// NewRouter builds the full HTTP router. +func NewRouter(authToken string, entrySvc *service.EntryService) http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + // Unauthenticated + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // Authenticated API + r.Route("/api", func(r chi.Router) { + r.Use(AuthMiddleware(authToken)) + + entryH := NewEntryHandler(entrySvc) + entryH.Routes(r) + }) + + return r +} diff --git a/internal/service/entry_service.go b/internal/service/entry_service.go new file mode 100644 index 0000000..322e155 --- /dev/null +++ b/internal/service/entry_service.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/wotra/wotra/internal/domain" + "github.com/wotra/wotra/internal/store" +) + +// Sentinel errors returned by the service layer. +var ( + ErrEntryRunning = errors.New("an entry is already running") + ErrEntryNotRunning = errors.New("no running entry found") + ErrEntryNotFound = errors.New("entry not found") + ErrDayAlreadyClosed = errors.New("day is already closed") + ErrDayNotClosed = errors.New("day is not closed") + ErrRunningEntryOnDay = errors.New("a running entry exists for this day; stop it first") + ErrCrossesMidnight = errors.New("entry end_time must be on the same calendar day as start_time") +) + +// EntryService handles business logic for time entries. +type EntryService struct { + entries *store.EntryStore + closedDays *store.ClosedDayStore + settings *store.SettingsStore + tz *time.Location +} + +func NewEntryService( + entries *store.EntryStore, + closedDays *store.ClosedDayStore, + settings *store.SettingsStore, + tz *time.Location, +) *EntryService { + return &EntryService{ + entries: entries, + closedDays: closedDays, + settings: settings, + tz: tz, + } +} + +func (s *EntryService) nowMs() int64 { + return time.Now().UnixMilli() +} + +func (s *EntryService) dayKeyForMs(ms int64) string { + t := time.UnixMilli(ms).In(s.tz) + return t.Format("2006-01-02") +} + +// midnightEndMs returns the unix ms of 23:59:59.999 for the day containing ms in the configured tz. +func (s *EntryService) midnightEndMs(ms int64) int64 { + t := time.UnixMilli(ms).In(s.tz) + end := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999_000_000, s.tz) + return end.UnixMilli() +} + +// Start creates a new running entry. Returns ErrEntryRunning if one is already active. +func (s *EntryService) Start(ctx context.Context, note string) (*domain.Entry, error) { + running, err := s.entries.RunningEntry(ctx) + if err != nil { + return nil, err + } + if running != nil { + return nil, ErrEntryRunning + } + + nowMs := s.nowMs() + dayKey := s.dayKeyForMs(nowMs) + + // Check day is not already closed + closed, err := s.closedDays.GetByDayKey(ctx, dayKey) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if closed != nil { + return nil, ErrDayAlreadyClosed + } + + e := &domain.Entry{ + ID: uuid.New().String(), + StartTime: nowMs, + Note: note, + DayKey: dayKey, + UpdatedAt: nowMs, + } + if err := s.entries.Create(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +// Stop stops the currently running entry. Returns ErrEntryNotRunning if none active. +func (s *EntryService) Stop(ctx context.Context) (*domain.Entry, error) { + running, err := s.entries.RunningEntry(ctx) + if err != nil { + return nil, err + } + if running == nil { + return nil, ErrEntryNotRunning + } + return s.stopEntry(ctx, running, false) +} + +// StopByID stops a specific entry by ID. +func (s *EntryService) StopByID(ctx context.Context, id string) (*domain.Entry, error) { + e, err := s.entries.GetByID(ctx, id) + if err == sql.ErrNoRows { + return nil, ErrEntryNotFound + } + if err != nil { + return nil, err + } + if !e.IsRunning() { + return nil, ErrEntryNotRunning + } + return s.stopEntry(ctx, e, false) +} + +func (s *EntryService) stopEntry(ctx context.Context, e *domain.Entry, autoStopped bool) (*domain.Entry, error) { + nowMs := s.nowMs() + + // Enforce same-day rule: cap end_time at 23:59:59.999 of start day + endMs := nowMs + startDayKey := s.dayKeyForMs(e.StartTime) + endDayKey := s.dayKeyForMs(endMs) + if endDayKey != startDayKey { + // Cross-midnight: cap at end of start day + endMs = s.midnightEndMs(e.StartTime) + autoStopped = true + } + + e.EndTime = &endMs + e.AutoStopped = autoStopped + e.UpdatedAt = nowMs + + if err := s.entries.Update(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +// GetByID returns a single entry. +func (s *EntryService) GetByID(ctx context.Context, id string) (*domain.Entry, error) { + e, err := s.entries.GetByID(ctx, id) + if err == sql.ErrNoRows { + return nil, ErrEntryNotFound + } + return e, err +} + +// List returns entries within a date range. +func (s *EntryService) List(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) { + return s.entries.ListByDateRange(ctx, fromDayKey, toDayKey) +} + +// UpdateEntry allows editing start/end/note for a non-running, non-closed entry. +type UpdateEntryInput struct { + StartTime *int64 + EndTime *int64 + Note *string +} + +func (s *EntryService) Update(ctx context.Context, id string, input UpdateEntryInput) (*domain.Entry, error) { + e, err := s.entries.GetByID(ctx, id) + if err == sql.ErrNoRows { + return nil, ErrEntryNotFound + } + if err != nil { + return nil, err + } + + if input.StartTime != nil { + e.StartTime = *input.StartTime + e.DayKey = s.dayKeyForMs(e.StartTime) + } + if input.EndTime != nil { + // Validate same-day + startDayKey := s.dayKeyForMs(e.StartTime) + endDayKey := s.dayKeyForMs(*input.EndTime) + if startDayKey != endDayKey { + return nil, ErrCrossesMidnight + } + if *input.EndTime < e.StartTime { + return nil, fmt.Errorf("end_time must be after start_time") + } + e.EndTime = input.EndTime + } + if input.Note != nil { + e.Note = *input.Note + } + + e.UpdatedAt = s.nowMs() + if err := s.entries.Update(ctx, e); err != nil { + return nil, err + } + return e, nil +} + +// Delete soft-deletes an entry. +func (s *EntryService) Delete(ctx context.Context, id string) error { + nowMs := s.nowMs() + return s.entries.SoftDelete(ctx, id, nowMs) +} + +// AutoStopStalledEntries stops any running entries whose day_key is before today. +// Called by the midnight background goroutine. +func (s *EntryService) AutoStopStalledEntries(ctx context.Context) ([]string, error) { + today := s.dayKeyForMs(s.nowMs()) + return s.entries.StopAllRunningBefore(ctx, today, s.nowMs(), s.nowMs()) +} diff --git a/internal/service/entry_service_test.go b/internal/service/entry_service_test.go new file mode 100644 index 0000000..1a07cda --- /dev/null +++ b/internal/service/entry_service_test.go @@ -0,0 +1,136 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "github.com/wotra/wotra/internal/service" + "github.com/wotra/wotra/internal/store" +) + +func newTestServices(t *testing.T) *service.EntryService { + 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") + return service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) +} + +func TestStartStop(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + entry, err := svc.Start(ctx, "test entry") + if err != nil { + t.Fatalf("Start: %v", err) + } + if entry.ID == "" { + t.Fatal("expected non-empty ID") + } + if entry.EndTime != nil { + t.Fatal("expected running entry (nil EndTime)") + } + + stopped, err := svc.Stop(ctx) + if err != nil { + t.Fatalf("Stop: %v", err) + } + if stopped.EndTime == nil { + t.Fatal("expected entry to be stopped") + } + if *stopped.EndTime < stopped.StartTime { + t.Fatal("end_time before start_time") + } +} + +func TestStartTwiceFails(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + if _, err := svc.Start(ctx, ""); err != nil { + t.Fatal(err) + } + _, err := svc.Start(ctx, "") + if err == nil { + t.Fatal("expected error starting second entry") + } + if err != service.ErrEntryRunning { + t.Fatalf("expected ErrEntryRunning, got %v", err) + } +} + +func TestStopWithoutStart(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + _, err := svc.Stop(ctx) + if err != service.ErrEntryNotRunning { + t.Fatalf("expected ErrEntryNotRunning, got %v", err) + } +} + +func TestUpdateEntry(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + entry, _ := svc.Start(ctx, "initial note") + stopped, _ := svc.Stop(ctx) + + note := "updated note" + updated, err := svc.Update(ctx, stopped.ID, service.UpdateEntryInput{Note: ¬e}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Note != "updated note" { + t.Errorf("expected 'updated note', got %q", updated.Note) + } + _ = entry +} + +func TestDeleteEntry(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + entry, _ := svc.Start(ctx, "") + svc.Stop(ctx) + + if err := svc.Delete(ctx, entry.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + entries, err := svc.List(ctx, "0000-01-01", "9999-12-31") + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected 0 entries after delete, got %d", len(entries)) + } +} + +func TestListEntries(t *testing.T) { + ctx := context.Background() + svc := newTestServices(t) + + for i := 0; i < 3; i++ { + svc.Start(ctx, "") + svc.Stop(ctx) + } + + today := time.Now().UTC().Format("2006-01-02") + entries, err := svc.List(ctx, today, today) + if err != nil { + t.Fatal(err) + } + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } +} diff --git a/internal/store/001_initial.sql b/internal/store/001_initial.sql new file mode 100644 index 0000000..198dd1c --- /dev/null +++ b/internal/store/001_initial.sql @@ -0,0 +1,61 @@ +-- +migrate Up +CREATE TABLE entries ( + id TEXT PRIMARY KEY, + start_time INTEGER NOT NULL, + end_time INTEGER, + auto_stopped INTEGER NOT NULL DEFAULT 0, + note TEXT, + day_key TEXT NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER +); +CREATE INDEX idx_entries_day ON entries(day_key); + +CREATE TABLE closed_days ( + day_key TEXT PRIMARY KEY, + start_time INTEGER, + end_time INTEGER, + worked_ms INTEGER NOT NULL, + kind TEXT NOT NULL, + closed_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE closed_weeks ( + week_key TEXT PRIMARY KEY, + expected_ms INTEGER NOT NULL, + worked_ms INTEGER NOT NULL, + delta_ms INTEGER NOT NULL, + closed_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE settings_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + effective_from TEXT NOT NULL, + hours_per_week REAL NOT NULL, + workdays_mask INTEGER NOT NULL DEFAULT 31, + timezone TEXT NOT NULL DEFAULT 'UTC', + created_at INTEGER NOT NULL +); + +CREATE TABLE sync_log ( + entity TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL, + version INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (entity, entity_id, version) +); + +-- seed default settings +INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at) +VALUES ('2000-01-01', 40.0, 31, 'UTC', unixepoch() * 1000); + +-- +migrate Down +DROP TABLE IF EXISTS sync_log; +DROP TABLE IF EXISTS settings_history; +DROP TABLE IF EXISTS closed_weeks; +DROP TABLE IF EXISTS closed_days; +DROP INDEX IF EXISTS idx_entries_day; +DROP TABLE IF EXISTS entries; diff --git a/internal/store/closed_day_store.go b/internal/store/closed_day_store.go new file mode 100644 index 0000000..7d21609 --- /dev/null +++ b/internal/store/closed_day_store.go @@ -0,0 +1,94 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/wotra/wotra/internal/domain" +) + +// ClosedDayStore handles persistence for closed days. +type ClosedDayStore struct { + db *sql.DB +} + +func NewClosedDayStore(db *sql.DB) *ClosedDayStore { + return &ClosedDayStore{db: db} +} + +func (s *ClosedDayStore) Upsert(ctx context.Context, d *domain.ClosedDay) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO closed_days (day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(day_key) DO UPDATE SET + start_time=excluded.start_time, end_time=excluded.end_time, + worked_ms=excluded.worked_ms, kind=excluded.kind, + closed_at=excluded.closed_at, updated_at=excluded.updated_at`, + d.DayKey, d.StartTime, d.EndTime, d.WorkedMs, d.Kind, d.ClosedAt, d.UpdatedAt, + ) + return err +} + +func (s *ClosedDayStore) Delete(ctx context.Context, dayKey string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM closed_days WHERE day_key=?`, dayKey) + return err +} + +func (s *ClosedDayStore) GetByDayKey(ctx context.Context, dayKey string) (*domain.ClosedDay, error) { + row := s.db.QueryRowContext(ctx, + `SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at + FROM closed_days WHERE day_key=?`, dayKey) + return scanClosedDay(row) +} + +func (s *ClosedDayStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.ClosedDay, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT day_key, start_time, end_time, worked_ms, kind, closed_at, updated_at + FROM closed_days WHERE day_key >= ? AND day_key <= ? ORDER BY day_key ASC`, + fromDayKey, toDayKey) + if err != nil { + return nil, err + } + defer rows.Close() + var result []*domain.ClosedDay + for rows.Next() { + d, err := scanClosedDayRow(rows) + if err != nil { + return nil, err + } + result = append(result, d) + } + return result, rows.Err() +} + +func scanClosedDay(row *sql.Row) (*domain.ClosedDay, error) { + var d domain.ClosedDay + var startTime, endTime sql.NullInt64 + err := row.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + if startTime.Valid { + d.StartTime = &startTime.Int64 + } + if endTime.Valid { + d.EndTime = &endTime.Int64 + } + return &d, nil +} + +func scanClosedDayRow(rows *sql.Rows) (*domain.ClosedDay, error) { + var d domain.ClosedDay + var startTime, endTime sql.NullInt64 + err := rows.Scan(&d.DayKey, &startTime, &endTime, &d.WorkedMs, &d.Kind, &d.ClosedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + if startTime.Valid { + d.StartTime = &startTime.Int64 + } + if endTime.Valid { + d.EndTime = &endTime.Int64 + } + return &d, nil +} diff --git a/internal/store/closed_week_store.go b/internal/store/closed_week_store.go new file mode 100644 index 0000000..983b92f --- /dev/null +++ b/internal/store/closed_week_store.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/wotra/wotra/internal/domain" +) + +// ClosedWeekStore handles persistence for closed weeks. +type ClosedWeekStore struct { + db *sql.DB +} + +func NewClosedWeekStore(db *sql.DB) *ClosedWeekStore { + return &ClosedWeekStore{db: db} +} + +func (s *ClosedWeekStore) Upsert(ctx context.Context, w *domain.ClosedWeek) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO closed_weeks (week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(week_key) DO UPDATE SET + expected_ms=excluded.expected_ms, worked_ms=excluded.worked_ms, + delta_ms=excluded.delta_ms, closed_at=excluded.closed_at, updated_at=excluded.updated_at`, + w.WeekKey, w.ExpectedMs, w.WorkedMs, w.DeltaMs, w.ClosedAt, w.UpdatedAt, + ) + return err +} + +func (s *ClosedWeekStore) Delete(ctx context.Context, weekKey string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM closed_weeks WHERE week_key=?`, weekKey) + return err +} + +func (s *ClosedWeekStore) GetByWeekKey(ctx context.Context, weekKey string) (*domain.ClosedWeek, error) { + row := s.db.QueryRowContext(ctx, + `SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at + FROM closed_weeks WHERE week_key=?`, weekKey) + var w domain.ClosedWeek + err := row.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt) + if err != nil { + return nil, err + } + return &w, nil +} + +func (s *ClosedWeekStore) ListByRange(ctx context.Context, fromWeekKey, toWeekKey string) ([]*domain.ClosedWeek, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT week_key, expected_ms, worked_ms, delta_ms, closed_at, updated_at + FROM closed_weeks WHERE week_key >= ? AND week_key <= ? ORDER BY week_key ASC`, + fromWeekKey, toWeekKey) + if err != nil { + return nil, err + } + defer rows.Close() + var result []*domain.ClosedWeek + for rows.Next() { + var w domain.ClosedWeek + if err := rows.Scan(&w.WeekKey, &w.ExpectedMs, &w.WorkedMs, &w.DeltaMs, &w.ClosedAt, &w.UpdatedAt); err != nil { + return nil, err + } + result = append(result, &w) + } + return result, rows.Err() +} + +// SumWorkedMsForWeek sums worked_ms across closed_days for the given day keys. +func SumWorkedMsForWeek(ctx context.Context, db *sql.DB, dayKeys []string) (int64, error) { + if len(dayKeys) == 0 { + return 0, nil + } + // Build IN clause + placeholders := make([]byte, 0, len(dayKeys)*2) + args := make([]interface{}, len(dayKeys)) + for i, k := range dayKeys { + if i > 0 { + placeholders = append(placeholders, ',') + } + placeholders = append(placeholders, '?') + args[i] = k + } + query := "SELECT COALESCE(SUM(worked_ms),0) FROM closed_days WHERE day_key IN (" + string(placeholders) + ")" + var total int64 + err := db.QueryRowContext(ctx, query, args...).Scan(&total) + return total, err +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..4a8be2f --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "database/sql" + _ "embed" + "fmt" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed 001_initial.sql +var schema string + +// Open opens (or creates) the SQLite database at path and runs migrations. +func Open(path string) (*sql.DB, error) { + dsn := fmt.Sprintf( + "file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)", + path, + ) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) // SQLite WAL: single writer + if err := migrate(db); err != nil { + db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + return db, nil +} + +// migrate runs embedded SQL migrations. Simple single-file approach: we track +// a user_version pragma and apply the schema once if version == 0. +func migrate(db *sql.DB) error { + var version int + if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return err + } + if version >= 1 { + return nil + } + + ctx := context.Background() + + // Strip migration comments and split into individual statements + stmts := splitStatements(schema) + for _, stmt := range stmts { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err) + } + } + + // PRAGMA user_version cannot be set inside a regular transaction in all SQLite versions; + // execute it as a standalone statement. + if _, err := db.ExecContext(ctx, "PRAGMA user_version = 1"); err != nil { + return err + } + return nil +} + +func splitStatements(sql string) []string { + // Only process statements up to the "-- +migrate Down" marker. + var lines []string + for _, line := range strings.Split(sql, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "-- +migrate Down" { + break + } + if strings.HasPrefix(trimmed, "-- +migrate") { + continue + } + lines = append(lines, line) + } + joined := strings.Join(lines, "\n") + // Split on semicolons + parts := strings.Split(joined, ";") + return parts +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/store/db_test.go b/internal/store/db_test.go new file mode 100644 index 0000000..c15c36e --- /dev/null +++ b/internal/store/db_test.go @@ -0,0 +1,26 @@ +package store_test + +import ( + "database/sql" + "testing" + + "github.com/wotra/wotra/internal/store" +) + +func TestMigration(t *testing.T) { + db, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"} + for _, tbl := range tables { + var name string + err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name) + if err == sql.ErrNoRows { + t.Errorf("table %q not created", tbl) + } else if err != nil { + t.Errorf("query for %q: %v", tbl, err) + } + } +} diff --git a/internal/store/entry_store.go b/internal/store/entry_store.go new file mode 100644 index 0000000..e06ec9e --- /dev/null +++ b/internal/store/entry_store.go @@ -0,0 +1,174 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + + "github.com/wotra/wotra/internal/domain" +) + +// EntryStore handles persistence for entries. +type EntryStore struct { + db *sql.DB +} + +func NewEntryStore(db *sql.DB) *EntryStore { + return &EntryStore{db: db} +} + +func (s *EntryStore) Create(ctx context.Context, e *domain.Entry) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO entries (id, start_time, end_time, auto_stopped, note, day_key, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, + ) + return err +} + +func (s *EntryStore) Update(ctx context.Context, e *domain.Entry) error { + _, err := s.db.ExecContext(ctx, + `UPDATE entries SET start_time=?, end_time=?, auto_stopped=?, note=?, day_key=?, updated_at=? + WHERE id=? AND deleted_at IS NULL`, + e.StartTime, e.EndTime, boolToInt(e.AutoStopped), e.Note, e.DayKey, e.UpdatedAt, e.ID, + ) + return err +} + +func (s *EntryStore) SoftDelete(ctx context.Context, id string, nowMs int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE entries SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + nowMs, nowMs, id, + ) + return err +} + +func (s *EntryStore) GetByID(ctx context.Context, id string) (*domain.Entry, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at + FROM entries WHERE id=?`, id) + return scanEntry(row) +} + +func (s *EntryStore) ListByDayKey(ctx context.Context, dayKey string) ([]*domain.Entry, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at + FROM entries WHERE day_key=? AND deleted_at IS NULL ORDER BY start_time ASC`, dayKey) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEntries(rows) +} + +func (s *EntryStore) ListByDateRange(ctx context.Context, fromDayKey, toDayKey string) ([]*domain.Entry, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at + FROM entries WHERE day_key >= ? AND day_key <= ? AND deleted_at IS NULL ORDER BY start_time ASC`, + fromDayKey, toDayKey) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEntries(rows) +} + +// RunningEntry returns the currently running entry (no end_time), if any. +func (s *EntryStore) RunningEntry(ctx context.Context) (*domain.Entry, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at + FROM entries WHERE end_time IS NULL AND deleted_at IS NULL LIMIT 1`) + e, err := scanEntry(row) + if err == sql.ErrNoRows { + return nil, nil + } + return e, err +} + +// RunningEntryForDay returns a running entry for a specific day, if any. +func (s *EntryStore) RunningEntryForDay(ctx context.Context, dayKey string) (*domain.Entry, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, start_time, end_time, auto_stopped, note, day_key, updated_at, deleted_at + FROM entries WHERE day_key=? AND end_time IS NULL AND deleted_at IS NULL LIMIT 1`, dayKey) + e, err := scanEntry(row) + if err == sql.ErrNoRows { + return nil, nil + } + return e, err +} + +// StopAllRunningBefore stops all entries whose day_key < dayKey with the given end time. +// Used for the midnight auto-stop. +func (s *EntryStore) StopAllRunningBefore(ctx context.Context, dayKey string, endTimeMs int64, nowMs int64) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id FROM entries WHERE end_time IS NULL AND deleted_at IS NULL AND day_key < ?`, dayKey) + if err != nil { + return nil, err + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + rows.Close() + for _, id := range ids { + if _, err := s.db.ExecContext(ctx, + `UPDATE entries SET end_time=?, auto_stopped=1, updated_at=? WHERE id=?`, + endTimeMs, nowMs, id); err != nil { + return nil, fmt.Errorf("auto-stop %s: %w", id, err) + } + } + return ids, nil +} + +func scanEntry(row *sql.Row) (*domain.Entry, error) { + var e domain.Entry + var endTime sql.NullInt64 + var deletedAt sql.NullInt64 + var autoStopped int + err := row.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt) + if err != nil { + return nil, err + } + if endTime.Valid { + e.EndTime = &endTime.Int64 + } + if deletedAt.Valid { + e.DeletedAt = &deletedAt.Int64 + } + e.AutoStopped = autoStopped != 0 + return &e, nil +} + +func scanEntries(rows *sql.Rows) ([]*domain.Entry, error) { + var result []*domain.Entry + for rows.Next() { + var e domain.Entry + var endTime sql.NullInt64 + var deletedAt sql.NullInt64 + var autoStopped int + if err := rows.Scan(&e.ID, &e.StartTime, &endTime, &autoStopped, &e.Note, &e.DayKey, &e.UpdatedAt, &deletedAt); err != nil { + return nil, err + } + if endTime.Valid { + e.EndTime = &endTime.Int64 + } + if deletedAt.Valid { + e.DeletedAt = &deletedAt.Int64 + } + e.AutoStopped = autoStopped != 0 + result = append(result, &e) + } + return result, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/store/settings_store.go b/internal/store/settings_store.go new file mode 100644 index 0000000..255325f --- /dev/null +++ b/internal/store/settings_store.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/wotra/wotra/internal/domain" +) + +// SettingsStore handles persistence for settings history. +type SettingsStore struct { + db *sql.DB +} + +func NewSettingsStore(db *sql.DB) *SettingsStore { + return &SettingsStore{db: db} +} + +// Current returns the most recent settings effective on or before the given day key. +func (s *SettingsStore) Current(ctx context.Context, asOfDayKey string) (*domain.Settings, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at + FROM settings_history + WHERE effective_from <= ? + ORDER BY effective_from DESC, id DESC + LIMIT 1`, asOfDayKey) + return scanSettings(row) +} + +// Latest returns the most recently created settings row. +func (s *SettingsStore) Latest(ctx context.Context) (*domain.Settings, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at + FROM settings_history + ORDER BY effective_from DESC, id DESC + LIMIT 1`) + return scanSettings(row) +} + +// History returns all settings rows ordered by effective_from DESC. +func (s *SettingsStore) History(ctx context.Context) ([]*domain.Settings, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, effective_from, hours_per_week, workdays_mask, timezone, created_at + FROM settings_history ORDER BY effective_from DESC, id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var result []*domain.Settings + for rows.Next() { + var s domain.Settings + if err := rows.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt); err != nil { + return nil, err + } + result = append(result, &s) + } + return result, rows.Err() +} + +// Insert inserts a new settings row. +func (s *SettingsStore) Insert(ctx context.Context, set *domain.Settings) error { + res, err := s.db.ExecContext(ctx, + `INSERT INTO settings_history (effective_from, hours_per_week, workdays_mask, timezone, created_at) + VALUES (?, ?, ?, ?, ?)`, + set.EffectiveFrom, set.HoursPerWeek, set.WorkdaysMask, set.Timezone, set.CreatedAt) + if err != nil { + return err + } + id, _ := res.LastInsertId() + set.ID = id + return nil +} + +func scanSettings(row *sql.Row) (*domain.Settings, error) { + var s domain.Settings + err := row.Scan(&s.ID, &s.EffectiveFrom, &s.HoursPerWeek, &s.WorkdaysMask, &s.Timezone, &s.CreatedAt) + if err != nil { + return nil, err + } + return &s, nil +}