feat(m1): backend scaffold - entries CRUD, start/stop, auth, migrations

This commit is contained in:
2026-04-30 16:35:06 +02:00
parent 4905c6f570
commit 3aa068efd2
19 changed files with 1483 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
wotra.db

105
cmd/wotra/main.go Normal file
View File

@@ -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)
}
}
}
}

17
go.mod Normal file
View File

@@ -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
)

23
go.sum Normal file
View File

@@ -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=

45
internal/config/config.go Normal file
View File

@@ -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
}

119
internal/domain/domain.go Normal file
View File

@@ -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
}

24
internal/handler/auth.go Normal file
View File

@@ -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)
})
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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: &note})
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))
}
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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
}

91
internal/store/db.go Normal file
View File

@@ -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
}

26
internal/store/db_test.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}