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
This commit is contained in:
2026-04-30 21:50:57 +02:00
parent 8ca838fa6e
commit 3214f48a6f
19 changed files with 1014 additions and 86 deletions

View File

@@ -11,13 +11,21 @@ import (
"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
entries *store.EntryStore
settings *store.SettingsStore
db interface {
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
@@ -29,12 +37,16 @@ func NewWeekService(
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,
@@ -44,17 +56,78 @@ func NewWeekService(
// BalanceResult holds the overall overtime/undertime balance across all closed weeks.
type BalanceResult struct {
TotalDeltaMs int64 `json:"total_delta_ms"`
ClosedWeekCount int `json:"closed_week_count"`
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 sum of delta_ms across all closed weeks.
// Balance returns the combined overtime balance: closed weeks + manual adjustments.
func (s *WeekService) Balance(ctx context.Context) (BalanceResult, error) {
total, count, err := s.closedWeeks.SumDelta(ctx)
weeksTotal, weekCount, err := s.closedWeeks.SumDelta(ctx)
if err != nil {
return BalanceResult{}, err
}
return BalanceResult{TotalDeltaMs: total, ClosedWeekCount: count}, nil
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.