Files
wotra/internal/service/week_service.go
Andreas Schneider 3214f48a6f Add balance adjustments (M8)
- New balance_adjustments table with CRUD store, sync logging, and service methods
- SQL migrations restructured: embed fs.FS from internal/store/migrations/, apply in order via user_version
- WeekService.Balance combines closed-weeks delta + adjustments delta; BalanceSummary breakdown
- Four REST routes: GET/POST /api/balance/adjustments, PUT/DELETE /api/balance/adjustments/{id}
- Dexie schema v2 + sync apply cases for balance_adjustments
- API client: BalanceAdjustment type, balance namespace (list/create/update/delete)
- utils: composeDeltaMs / decomposeDeltaMs helpers + 8 new Vitest tests (19 total, all passing)
- History page: balance card breakdown line + full adjustments section with inline add/edit/delete
2026-04-30 21:50:57 +02:00

273 lines
8.4 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/store"
)
// Sentinel errors for balance adjustments.
var (
ErrZeroAdjustment = errors.New("delta_ms must be non-zero")
ErrAdjustmentNotFound = errors.New("balance adjustment not found")
)
// WeekService handles closing weeks and computing overtime/undertime.
type WeekService struct {
closedDays *store.ClosedDayStore
closedWeeks *store.ClosedWeekStore
adjustments *store.BalanceAdjustmentStore
syncStore *store.SyncStore
entries *store.EntryStore
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,
entries *store.EntryStore,
settings *store.SettingsStore,
adjustments *store.BalanceAdjustmentStore,
syncStore *store.SyncStore,
rawDB *sql.DB,
tz *time.Location,
) *WeekService {
return &WeekService{
closedDays: closedDays,
closedWeeks: closedWeeks,
adjustments: adjustments,
syncStore: syncStore,
entries: entries,
settings: settings,
rawDB: rawDB,
tz: tz,
}
}
// BalanceResult holds the overall overtime/undertime balance across all closed weeks.
type BalanceResult struct {
TotalDeltaMs int64 `json:"total_delta_ms"`
WeeksDeltaMs int64 `json:"weeks_delta_ms"`
AdjustmentsDeltaMs int64 `json:"adjustments_delta_ms"`
ClosedWeekCount int `json:"closed_week_count"`
AdjustmentCount int `json:"adjustment_count"`
}
// Balance returns the combined overtime balance: closed weeks + manual adjustments.
func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) {
weeksTotal, weekCount, err := s.closedWeeks.SumDelta(ctx)
if err != nil {
return BalanceResult{}, err
}
adjTotal, adjCount, err := s.adjustments.SumDelta(ctx)
if err != nil {
return BalanceResult{}, err
}
return BalanceResult{
TotalDeltaMs: weeksTotal + adjTotal,
WeeksDeltaMs: weeksTotal,
AdjustmentsDeltaMs: adjTotal,
ClosedWeekCount: weekCount,
AdjustmentCount: adjCount,
}, nil
}
// ListAdjustments returns all balance adjustments ordered by effective_at DESC.
func (s *WeekService) ListAdjustments(ctx context.Context) ([]*domain.BalanceAdjustment, error) {
return s.adjustments.List(ctx)
}
// CreateAdjustment creates and sync-logs a new balance adjustment.
func (s *WeekService) CreateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) {
if a.DeltaMs == 0 {
return nil, ErrZeroAdjustment
}
if err := s.adjustments.Create(ctx, a); err != nil {
return nil, err
}
_ = s.syncStore.LogBalanceAdjustment(ctx, a) // best-effort
return a, nil
}
// UpdateAdjustment updates and sync-logs an existing balance adjustment.
func (s *WeekService) UpdateAdjustment(ctx context.Context, a *domain.BalanceAdjustment) (*domain.BalanceAdjustment, error) {
if a.DeltaMs == 0 {
return nil, ErrZeroAdjustment
}
if err := s.adjustments.Update(ctx, a); err != nil {
if errors.Is(err, store.ErrAdjustmentNotFound) {
return nil, ErrAdjustmentNotFound
}
return nil, err
}
got, err := s.adjustments.GetByID(ctx, a.ID)
if err != nil {
return nil, err
}
_ = s.syncStore.LogBalanceAdjustment(ctx, got) // best-effort
return got, nil
}
// DeleteAdjustment deletes and sync-logs a balance adjustment.
func (s *WeekService) DeleteAdjustment(ctx context.Context, id string) error {
if err := s.adjustments.Delete(ctx, id); err != nil {
if errors.Is(err, store.ErrAdjustmentNotFound) {
return ErrAdjustmentNotFound
}
return err
}
_ = s.syncStore.LogBalanceAdjustmentDelete(ctx, id) // best-effort
return nil
}
// WeekDayKeysExported is exported for testing.
var WeekDayKeysExported = weekDayKeys
// WeekKeyForDayKey returns the ISO week key (YYYY-Www) for a given YYYY-MM-DD day key.
func WeekKeyForDayKey(dayKey string, tz *time.Location) (string, error) {
t, err := time.ParseInLocation("2006-01-02", dayKey, tz)
if err != nil {
return "", fmt.Errorf("invalid day_key %q: %w", dayKey, err)
}
year, week := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", year, week), nil
}
// 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)
// (weekday+6)%7 gives days-since-Monday (Mon=0 … Sun=6), avoiding the
// sign issue when Weekday()==0 (Sunday) with the naive subtraction.
daysSinceMonday := int(jan4.Weekday()+6) % 7
_, jan4Week := jan4.ISOWeek()
monday := jan4.AddDate(0, 0, -daysSinceMonday+(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 effective at close time (today), not necessarily at the
// start of the week. This ensures settings changes made mid-week are
// reflected when the week is closed.
set, err := s.settings.Current(ctx, time.Now().In(s.tz).Format("2006-01-02"))
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 past workdays that have entries are closed; collect worked ms.
// Past workdays with no entries at all are skipped (they contribute 0h).
// Future workdays in the week are always skipped.
todayKey := time.Now().In(s.tz).Format("2006-01-02")
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
}
if dk > todayKey {
continue // future workday — skip
}
cd, err := s.closedDays.GetByDayKey(ctx, dk)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if cd != nil {
totalWorkedMs += cd.WorkedMs
continue
}
// No closed_days record — check whether the day has any entries.
dayEntries, err := s.entries.ListByDayKey(ctx, dk)
if err != nil {
return nil, err
}
if len(dayEntries) > 0 {
// Day has tracked time but was never closed — require explicit close.
return nil, fmt.Errorf("%w: %s", ErrWeekHasUnclosedDays, dk)
}
// No entries, no closed record → untracked day, counts as 0h.
}
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
}