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:
31
PLAN.md
31
PLAN.md
@@ -88,13 +88,25 @@ CREATE TABLE settings_history (
|
||||
|
||||
-- Sync event log (last-write-wins per entity row)
|
||||
CREATE TABLE sync_log (
|
||||
entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks'
|
||||
entity TEXT NOT NULL, -- 'entries' | 'closed_days' | 'closed_weeks' | 'balance_adjustments'
|
||||
entity_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL, -- 'upsert' | 'delete'
|
||||
version INTEGER NOT NULL, -- monotonic server-assigned
|
||||
payload TEXT NOT NULL, -- JSON snapshot
|
||||
PRIMARY KEY (entity, entity_id, version)
|
||||
);
|
||||
|
||||
-- Balance adjustments: manual corrective entries that modify the overall overtime balance
|
||||
-- without touching week math. Signed delta_ms (positive = credit, negative = debit).
|
||||
CREATE TABLE balance_adjustments (
|
||||
id TEXT PRIMARY KEY, -- client-generated UUIDv7 (sync-friendly)
|
||||
delta_ms INTEGER NOT NULL, -- signed; non-zero enforced at service layer
|
||||
note TEXT NOT NULL DEFAULT '', -- optional free-text reason
|
||||
effective_at INTEGER NOT NULL, -- unix ms; backdatable
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at);
|
||||
```
|
||||
|
||||
### API Surface (REST/JSON)
|
||||
@@ -118,6 +130,13 @@ DELETE /api/days/{day_key}/close (reopen day)
|
||||
GET /api/weeks?from=YYYY-Www&to=YYYY-Www
|
||||
POST /api/weeks/{week_key}/close -> ClosedWeek
|
||||
DELETE /api/weeks/{week_key}/close (reopen week)
|
||||
GET /api/weeks/balance -> BalanceSummary
|
||||
|
||||
# Balance adjustments
|
||||
GET /api/balance/adjustments -> []BalanceAdjustment
|
||||
POST /api/balance/adjustments { delta_ms, note?, effective_at } -> BalanceAdjustment
|
||||
PUT /api/balance/adjustments/{id} { delta_ms, note?, effective_at } -> BalanceAdjustment
|
||||
DELETE /api/balance/adjustments/{id}
|
||||
|
||||
# Settings
|
||||
GET /api/settings # current effective settings
|
||||
@@ -243,7 +262,11 @@ Staged implementation:
|
||||
| 4 | Backend guard: reject future-day intervals | pending |
|
||||
| 5 | Bottom nav Today shortcut; delete /today route | pending |
|
||||
|
||||
### M8 — Future
|
||||
### M8 — Balance adjustments ✅
|
||||
|
||||
Manual corrective entries on the History page that adjust the overall overtime balance without touching week math. Separate `balance_adjustments` table with signed `delta_ms`, optional `note`, and `effective_at` (backdatable). Balance summary combines `Σ closed_weeks.delta_ms + Σ balance_adjustments.delta_ms`.
|
||||
|
||||
### M9 — Future
|
||||
CSV/JSON export, monthly summary view.
|
||||
|
||||
## 7. Decisions & Rationale
|
||||
@@ -260,5 +283,7 @@ CSV/JSON export, monthly summary view.
|
||||
| Week close settings | Use close-time date, not week Monday | Settings changed mid-week apply to that week's close |
|
||||
| weekDayKeys formula | `(weekday+6)%7` for days-since-Monday | Avoids sign bug when Jan 4 is Sunday (affects year 2026) |
|
||||
| Merged Today+Week | Single `/week` route with day selection | Reduces duplication; week context always visible |
|
||||
| Future intervals | Rejected by server (400) | Nonsensical to track time that hasn't happened |
|
||||
| Balance adjustments | Separate `balance_adjustments` table | Avoids conflating measured deltas with manual corrections; preserves week math invariant (`delta_ms = worked - expected`) |
|
||||
| Balance adjustment scope | Closed weeks only for auto balance | In-progress week delta shown separately in week view; mixing would make balance jitter |
|
||||
| Balance adjustment IDs | TEXT (UUIDv7, client-generated) | Consistent with `entries`; allows offline creation and sync |
|
||||
| Test framework | Vitest (frontend) + Go testing (backend) | Automated coverage for capability logic and key utilities |
|
||||
|
||||
@@ -42,13 +42,14 @@ func main() {
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
closedWeekStore := store.NewClosedWeekStore(db)
|
||||
adjustmentStore := store.NewBalanceAdjustmentStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
syncStore := store.NewSyncStore(db)
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
|
||||
settingsSvc := service.NewSettingsService(settingsStore)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
|
||||
|
||||
// Background goroutine: auto-stop entries that cross midnight
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -109,6 +109,18 @@ func (s *Settings) IsWorkday(wd int) bool {
|
||||
return s.WorkdaysMask&bit != 0
|
||||
}
|
||||
|
||||
// BalanceAdjustment is a manual corrective entry that adjusts the overall
|
||||
// overtime balance without touching week math. DeltaMs is signed: positive
|
||||
// credits time, negative debits time.
|
||||
type BalanceAdjustment struct {
|
||||
ID string `json:"id"` // UUIDv7, client-generated
|
||||
DeltaMs int64 `json:"delta_ms"` // signed, non-zero
|
||||
Note string `json:"note"` // optional free-text reason
|
||||
EffectiveAt int64 `json:"effective_at"` // unix ms, backdatable
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func popcount(n int) int {
|
||||
count := 0
|
||||
for n != 0 {
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/service"
|
||||
)
|
||||
|
||||
// WeekHandler serves /api/weeks routes.
|
||||
// WeekHandler serves /api/weeks and /api/balance/adjustments routes.
|
||||
type WeekHandler struct {
|
||||
svc *service.WeekService
|
||||
}
|
||||
@@ -22,6 +26,11 @@ func (h *WeekHandler) Routes(r chi.Router) {
|
||||
r.Get("/weeks/balance", h.Balance)
|
||||
r.Post("/weeks/{week_key}/close", h.Close)
|
||||
r.Delete("/weeks/{week_key}/close", h.Reopen)
|
||||
|
||||
r.Get("/balance/adjustments", h.ListAdjustments)
|
||||
r.Post("/balance/adjustments", h.CreateAdjustment)
|
||||
r.Put("/balance/adjustments/{id}", h.UpdateAdjustment)
|
||||
r.Delete("/balance/adjustments/{id}", h.DeleteAdjustment)
|
||||
}
|
||||
|
||||
// Balance GET /api/weeks/balance
|
||||
@@ -90,3 +99,107 @@ func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ── Balance adjustments ───────────────────────────────────────────────────────
|
||||
|
||||
// ListAdjustments GET /api/balance/adjustments
|
||||
func (h *WeekHandler) ListAdjustments(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.svc.ListAdjustments(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
writeJSON(w, http.StatusOK, []*domain.BalanceAdjustment{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
type adjustmentRequest struct {
|
||||
DeltaMs int64 `json:"delta_ms"`
|
||||
Note string `json:"note"`
|
||||
EffectiveAt *int64 `json:"effective_at"` // optional; defaults to now
|
||||
}
|
||||
|
||||
// CreateAdjustment POST /api/balance/adjustments
|
||||
func (h *WeekHandler) CreateAdjustment(w http.ResponseWriter, r *http.Request) {
|
||||
var req adjustmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
effectiveAt := now
|
||||
if req.EffectiveAt != nil {
|
||||
effectiveAt = *req.EffectiveAt
|
||||
}
|
||||
a := &domain.BalanceAdjustment{
|
||||
ID: uuid.New().String(),
|
||||
DeltaMs: req.DeltaMs,
|
||||
Note: req.Note,
|
||||
EffectiveAt: effectiveAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
created, err := h.svc.CreateAdjustment(r.Context(), a)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrZeroAdjustment) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// UpdateAdjustment PUT /api/balance/adjustments/{id}
|
||||
func (h *WeekHandler) UpdateAdjustment(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req adjustmentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
effectiveAt := now
|
||||
if req.EffectiveAt != nil {
|
||||
effectiveAt = *req.EffectiveAt
|
||||
}
|
||||
a := &domain.BalanceAdjustment{
|
||||
ID: id,
|
||||
DeltaMs: req.DeltaMs,
|
||||
Note: req.Note,
|
||||
EffectiveAt: effectiveAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
updated, err := h.svc.UpdateAdjustment(r.Context(), a)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrZeroAdjustment):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, service.ErrAdjustmentNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// DeleteAdjustment DELETE /api/balance/adjustments/{id}
|
||||
func (h *WeekHandler) DeleteAdjustment(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.svc.DeleteAdjustment(r.Context(), id); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAdjustmentNotFound):
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ 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
|
||||
adjustments *store.BalanceAdjustmentStore
|
||||
syncStore *store.SyncStore
|
||||
entries *store.EntryStore
|
||||
settings *store.SettingsStore
|
||||
db interface {
|
||||
@@ -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,
|
||||
@@ -45,16 +57,77 @@ func NewWeekService(
|
||||
// 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 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.
|
||||
|
||||
@@ -22,12 +22,14 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
|
||||
entryStore := store.NewEntryStore(db)
|
||||
closedDayStore := store.NewClosedDayStore(db)
|
||||
closedWeekStore := store.NewClosedWeekStore(db)
|
||||
adjustmentStore := store.NewBalanceAdjustmentStore(db)
|
||||
syncStore := store.NewSyncStore(db)
|
||||
settingsStore := store.NewSettingsStore(db)
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
|
||||
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
|
||||
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, db, tz)
|
||||
weekSvc := service.NewWeekService(closedDayStore, closedWeekStore, entryStore, settingsStore, adjustmentStore, syncStore, db, tz)
|
||||
settingsSvc := service.NewSettingsService(settingsStore)
|
||||
return entrySvc, daySvc, weekSvc, settingsSvc
|
||||
}
|
||||
@@ -246,13 +248,13 @@ func TestWeekServiceBalance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, daySvc, weekSvc, _ := newFullServices(t)
|
||||
|
||||
// Empty — no closed weeks yet.
|
||||
// Empty — no closed weeks, no adjustments.
|
||||
bal, err := weekSvc.Balance(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance (empty): %v", err)
|
||||
}
|
||||
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 {
|
||||
t.Errorf("empty: want {0,0}, got {%d,%d}", bal.TotalDeltaMs, bal.ClosedWeekCount)
|
||||
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 || bal.AdjustmentCount != 0 {
|
||||
t.Errorf("empty: want all zeros, got %+v", bal)
|
||||
}
|
||||
|
||||
// Close two weeks as holiday (worked = expected → delta = 0 each).
|
||||
@@ -279,10 +281,73 @@ func TestWeekServiceBalance(t *testing.T) {
|
||||
t.Fatalf("Balance (populated): %v", err)
|
||||
}
|
||||
if bal.ClosedWeekCount != 2 {
|
||||
t.Errorf("count: want 2, got %d", bal.ClosedWeekCount)
|
||||
t.Errorf("closed_week_count: want 2, got %d", bal.ClosedWeekCount)
|
||||
}
|
||||
// Both weeks: worked == expected (holiday marks = full hours), delta = 0.
|
||||
if bal.TotalDeltaMs != 0 {
|
||||
t.Errorf("total_delta_ms: want 0, got %d", bal.TotalDeltaMs)
|
||||
if bal.TotalDeltaMs != 0 || bal.WeeksDeltaMs != 0 {
|
||||
t.Errorf("weeks-only total: want 0, got %+v", bal)
|
||||
}
|
||||
|
||||
// Add a +2h adjustment.
|
||||
now := time.Now().UnixMilli()
|
||||
adj, err := weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{
|
||||
ID: "adj-1", DeltaMs: 7_200_000, Note: "carry-over",
|
||||
EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAdjustment: %v", err)
|
||||
}
|
||||
if adj.ID != "adj-1" {
|
||||
t.Errorf("CreateAdjustment ID: want adj-1, got %s", adj.ID)
|
||||
}
|
||||
|
||||
bal, err = weekSvc.Balance(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Balance with adjustment: %v", err)
|
||||
}
|
||||
if bal.TotalDeltaMs != 7_200_000 {
|
||||
t.Errorf("total with adj: want 7200000, got %d", bal.TotalDeltaMs)
|
||||
}
|
||||
if bal.AdjustmentsDeltaMs != 7_200_000 {
|
||||
t.Errorf("adjustments_delta_ms: want 7200000, got %d", bal.AdjustmentsDeltaMs)
|
||||
}
|
||||
if bal.AdjustmentCount != 1 {
|
||||
t.Errorf("adjustment_count: want 1, got %d", bal.AdjustmentCount)
|
||||
}
|
||||
|
||||
// Zero delta rejected.
|
||||
_, err = weekSvc.CreateAdjustment(ctx, &domain.BalanceAdjustment{
|
||||
ID: "adj-zero", DeltaMs: 0, EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != service.ErrZeroAdjustment {
|
||||
t.Errorf("zero delta: want ErrZeroAdjustment, got %v", err)
|
||||
}
|
||||
|
||||
// Update adjustment.
|
||||
adj.DeltaMs = 3_600_000
|
||||
adj.UpdatedAt = now + 1
|
||||
updated, err := weekSvc.UpdateAdjustment(ctx, adj)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateAdjustment: %v", err)
|
||||
}
|
||||
if updated.DeltaMs != 3_600_000 {
|
||||
t.Errorf("updated delta_ms: want 3600000, got %d", updated.DeltaMs)
|
||||
}
|
||||
bal, _ = weekSvc.Balance(ctx)
|
||||
if bal.TotalDeltaMs != 3_600_000 {
|
||||
t.Errorf("balance after update: want 3600000, got %d", bal.TotalDeltaMs)
|
||||
}
|
||||
|
||||
// Delete adjustment.
|
||||
if err := weekSvc.DeleteAdjustment(ctx, "adj-1"); err != nil {
|
||||
t.Fatalf("DeleteAdjustment: %v", err)
|
||||
}
|
||||
bal, _ = weekSvc.Balance(ctx)
|
||||
if bal.TotalDeltaMs != 0 || bal.AdjustmentCount != 0 {
|
||||
t.Errorf("balance after delete: want 0/0, got %d/%d", bal.TotalDeltaMs, bal.AdjustmentCount)
|
||||
}
|
||||
|
||||
// Delete missing.
|
||||
if err := weekSvc.DeleteAdjustment(ctx, "no-such"); err != service.ErrAdjustmentNotFound {
|
||||
t.Errorf("delete missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
100
internal/store/balance_adjustment_store.go
Normal file
100
internal/store/balance_adjustment_store.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
)
|
||||
|
||||
// BalanceAdjustmentStore handles persistence for balance_adjustments.
|
||||
type BalanceAdjustmentStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewBalanceAdjustmentStore(db *sql.DB) *BalanceAdjustmentStore {
|
||||
return &BalanceAdjustmentStore{db: db}
|
||||
}
|
||||
|
||||
func (s *BalanceAdjustmentStore) Create(ctx context.Context, a *domain.BalanceAdjustment) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO balance_adjustments (id, delta_ms, note, effective_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
a.ID, a.DeltaMs, a.Note, a.EffectiveAt, a.CreatedAt, a.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *BalanceAdjustmentStore) Update(ctx context.Context, a *domain.BalanceAdjustment) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE balance_adjustments SET delta_ms=?, note=?, effective_at=?, updated_at=? WHERE id=?`,
|
||||
a.DeltaMs, a.Note, a.EffectiveAt, a.UpdatedAt, a.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrAdjustmentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BalanceAdjustmentStore) Delete(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM balance_adjustments WHERE id=?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrAdjustmentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BalanceAdjustmentStore) GetByID(ctx context.Context, id string) (*domain.BalanceAdjustment, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, delta_ms, note, effective_at, created_at, updated_at
|
||||
FROM balance_adjustments WHERE id=?`, id)
|
||||
var a domain.BalanceAdjustment
|
||||
err := row.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrAdjustmentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// List returns all adjustments ordered by effective_at DESC.
|
||||
func (s *BalanceAdjustmentStore) List(ctx context.Context) ([]*domain.BalanceAdjustment, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, delta_ms, note, effective_at, created_at, updated_at
|
||||
FROM balance_adjustments ORDER BY effective_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []*domain.BalanceAdjustment
|
||||
for rows.Next() {
|
||||
var a domain.BalanceAdjustment
|
||||
if err := rows.Scan(&a.ID, &a.DeltaMs, &a.Note, &a.EffectiveAt, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, &a)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// SumDelta returns the sum of delta_ms and row count across all adjustments.
|
||||
func (s *BalanceAdjustmentStore) SumDelta(ctx context.Context) (totalDeltaMs int64, count int, err error) {
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(delta_ms), 0), COUNT(*) FROM balance_adjustments`,
|
||||
).Scan(&totalDeltaMs, &count)
|
||||
return
|
||||
}
|
||||
|
||||
// ErrAdjustmentNotFound is returned when no balance_adjustment row matches the given ID.
|
||||
var ErrAdjustmentNotFound = errors.New("balance adjustment not found")
|
||||
128
internal/store/balance_adjustment_store_test.go
Normal file
128
internal/store/balance_adjustment_store_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wotra/wotra/internal/domain"
|
||||
"github.com/wotra/wotra/internal/store"
|
||||
)
|
||||
|
||||
func TestBalanceAdjustmentStoreCRUD(t *testing.T) {
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
s := store.NewBalanceAdjustmentStore(db)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
// SumDelta on empty table.
|
||||
total, count, err := s.SumDelta(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SumDelta empty: %v", err)
|
||||
}
|
||||
if total != 0 || count != 0 {
|
||||
t.Fatalf("empty: want (0,0), got (%d,%d)", total, count)
|
||||
}
|
||||
|
||||
// List on empty table returns nil slice, no error.
|
||||
list, err := s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List empty: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("List empty: want 0, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create two adjustments.
|
||||
a1 := &domain.BalanceAdjustment{
|
||||
ID: "id-1", DeltaMs: 3_600_000, Note: "carry-over",
|
||||
EffectiveAt: now - 86400000, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
a2 := &domain.BalanceAdjustment{
|
||||
ID: "id-2", DeltaMs: -1_800_000, Note: "",
|
||||
EffectiveAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.Create(ctx, a1); err != nil {
|
||||
t.Fatalf("Create a1: %v", err)
|
||||
}
|
||||
if err := s.Create(ctx, a2); err != nil {
|
||||
t.Fatalf("Create a2: %v", err)
|
||||
}
|
||||
|
||||
// GetByID round-trip.
|
||||
got, err := s.GetByID(ctx, "id-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.DeltaMs != a1.DeltaMs || got.Note != a1.Note {
|
||||
t.Errorf("GetByID: want {%d,%q}, got {%d,%q}", a1.DeltaMs, a1.Note, got.DeltaMs, got.Note)
|
||||
}
|
||||
|
||||
// GetByID missing.
|
||||
_, err = s.GetByID(ctx, "no-such")
|
||||
if err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("GetByID missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// SumDelta with two rows.
|
||||
total, count, err = s.SumDelta(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("SumDelta: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("count: want 2, got %d", count)
|
||||
}
|
||||
want := int64(3_600_000 - 1_800_000)
|
||||
if total != want {
|
||||
t.Errorf("total: want %d, got %d", want, total)
|
||||
}
|
||||
|
||||
// List returns newest effective_at first (a2 has now, a1 has now-1day).
|
||||
list, err = s.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("List len: want 2, got %d", len(list))
|
||||
}
|
||||
if list[0].ID != "id-2" {
|
||||
t.Errorf("List[0]: want id-2, got %s", list[0].ID)
|
||||
}
|
||||
|
||||
// Update a1.
|
||||
a1.DeltaMs = 7_200_000
|
||||
a1.Note = "updated"
|
||||
a1.UpdatedAt = now + 1000
|
||||
if err := s.Update(ctx, a1); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
got, _ = s.GetByID(ctx, "id-1")
|
||||
if got.DeltaMs != 7_200_000 || got.Note != "updated" {
|
||||
t.Errorf("after Update: want {7200000,updated}, got {%d,%q}", got.DeltaMs, got.Note)
|
||||
}
|
||||
|
||||
// Update missing.
|
||||
missing := &domain.BalanceAdjustment{ID: "no-such", DeltaMs: 1, UpdatedAt: now}
|
||||
if err := s.Update(ctx, missing); err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("Update missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Delete a2.
|
||||
if err := s.Delete(ctx, "id-2"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
total, count, _ = s.SumDelta(ctx)
|
||||
if count != 1 || total != 7_200_000 {
|
||||
t.Errorf("after Delete: want (7200000,1), got (%d,%d)", total, count)
|
||||
}
|
||||
|
||||
// Delete missing.
|
||||
if err := s.Delete(ctx, "no-such"); err != store.ErrAdjustmentNotFound {
|
||||
t.Errorf("Delete missing: want ErrAdjustmentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,18 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed 001_initial.sql
|
||||
var schema string
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Open opens (or creates) the SQLite database at path and runs migrations.
|
||||
func Open(path string) (*sql.DB, error) {
|
||||
@@ -31,22 +34,51 @@ func Open(path string) (*sql.DB, error) {
|
||||
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.
|
||||
// migrate applies all SQL migration files in migrations/ in filename order.
|
||||
// user_version tracks the last applied migration index (1-based).
|
||||
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
|
||||
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
// Sort by name so 001_*, 002_* apply in order.
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Strip migration comments and split into individual statements
|
||||
stmts := splitStatements(schema)
|
||||
for _, stmt := range stmts {
|
||||
for i, entry := range entries {
|
||||
migrationNum := i + 1 // 1-based
|
||||
if version >= migrationNum {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := migrationsFS.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := applySchema(db, ctx, string(data)); err != nil {
|
||||
return fmt.Errorf("migration %d (%s): %w", migrationNum, entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", migrationNum)); err != nil {
|
||||
return fmt.Errorf("set user_version = %d: %w", migrationNum, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applySchema(db *sql.DB, ctx context.Context, sql string) error {
|
||||
for _, stmt := range splitStatements(sql) {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
@@ -55,12 +87,6 @@ func migrate(db *sql.DB) error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log"}
|
||||
tables := []string{"entries", "closed_days", "closed_weeks", "settings_history", "sync_log", "balance_adjustments"}
|
||||
for _, tbl := range tables {
|
||||
var name string
|
||||
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name)
|
||||
|
||||
14
internal/store/migrations/002_balance_adjustments.sql
Normal file
14
internal/store/migrations/002_balance_adjustments.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- +migrate Up
|
||||
CREATE TABLE balance_adjustments (
|
||||
id TEXT PRIMARY KEY,
|
||||
delta_ms INTEGER NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
effective_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_balance_adjustments_effective_at ON balance_adjustments(effective_at);
|
||||
|
||||
-- +migrate Down
|
||||
DROP INDEX IF EXISTS idx_balance_adjustments_effective_at;
|
||||
DROP TABLE IF EXISTS balance_adjustments;
|
||||
@@ -96,6 +96,21 @@ func (s *SyncStore) LogClosedWeek(ctx context.Context, w *domain.ClosedWeek) err
|
||||
return s.log(ctx, "closed_weeks", w.WeekKey, "upsert", string(payload))
|
||||
}
|
||||
|
||||
// LogBalanceAdjustment appends a balance_adjustment upsert to the sync log.
|
||||
func (s *SyncStore) LogBalanceAdjustment(ctx context.Context, a *domain.BalanceAdjustment) error {
|
||||
payload, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.log(ctx, "balance_adjustments", a.ID, "upsert", string(payload))
|
||||
}
|
||||
|
||||
// LogBalanceAdjustmentDelete appends a balance_adjustment delete to the sync log.
|
||||
func (s *SyncStore) LogBalanceAdjustmentDelete(ctx context.Context, id string) error {
|
||||
payload := fmt.Sprintf(`{"id":%q}`, id)
|
||||
return s.log(ctx, "balance_adjustments", id, "delete", payload)
|
||||
}
|
||||
|
||||
func (s *SyncStore) log(ctx context.Context, entity, entityID, op, payload string) error {
|
||||
version, err := s.nextVersion(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,6 +81,23 @@ export interface Settings {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface BalanceAdjustment {
|
||||
id: string;
|
||||
delta_ms: number;
|
||||
note: string;
|
||||
effective_at: number; // unix ms
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface BalanceSummary {
|
||||
total_delta_ms: number;
|
||||
weeks_delta_ms: number;
|
||||
adjustments_delta_ms: number;
|
||||
closed_week_count: number;
|
||||
adjustment_count: number;
|
||||
}
|
||||
|
||||
// ─── Entries ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const entries = {
|
||||
@@ -125,7 +142,18 @@ export const weeks = {
|
||||
},
|
||||
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
|
||||
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`),
|
||||
balance: () => request<{ total_delta_ms: number; closed_week_count: number }>('GET', '/weeks/balance')
|
||||
balance: () => request<BalanceSummary>('GET', '/weeks/balance')
|
||||
};
|
||||
|
||||
// ─── Balance adjustments ─────────────────────────────────────────────────────
|
||||
|
||||
export const balance = {
|
||||
list: () => request<BalanceAdjustment[]>('GET', '/balance/adjustments'),
|
||||
create: (body: { delta_ms: number; note?: string; effective_at?: number }) =>
|
||||
request<BalanceAdjustment>('POST', '/balance/adjustments', body),
|
||||
update: (id: string, body: { delta_ms: number; note?: string; effective_at?: number }) =>
|
||||
request<BalanceAdjustment>('PUT', `/balance/adjustments/${id}`, body),
|
||||
delete: (id: string) => request<void>('DELETE', `/balance/adjustments/${id}`)
|
||||
};
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Entry, ClosedDay, ClosedWeek, Settings } from '$lib/api/client';
|
||||
import type { Entry, ClosedDay, ClosedWeek, Settings, BalanceAdjustment } from '$lib/api/client';
|
||||
|
||||
export interface OutboxItem {
|
||||
id?: number; // auto-increment
|
||||
entity: string; // 'entries' | 'closed_days' | 'closed_weeks' | 'settings'
|
||||
entity: string; // 'entries' | 'closed_days' | 'closed_weeks' | 'settings' | 'balance_adjustments'
|
||||
entity_id: string;
|
||||
op: 'upsert' | 'delete';
|
||||
payload: string; // JSON
|
||||
@@ -15,6 +15,7 @@ export class WotraDB extends Dexie {
|
||||
closed_days!: Table<ClosedDay, string>;
|
||||
closed_weeks!: Table<ClosedWeek, string>;
|
||||
settings_history!: Table<Settings, number>;
|
||||
balance_adjustments!: Table<BalanceAdjustment, string>;
|
||||
outbox!: Table<OutboxItem, number>;
|
||||
meta!: Table<{ key: string; value: string }, string>;
|
||||
|
||||
@@ -28,6 +29,9 @@ export class WotraDB extends Dexie {
|
||||
outbox: '++id, entity, entity_id',
|
||||
meta: 'key'
|
||||
});
|
||||
this.version(2).stores({
|
||||
balance_adjustments: 'id, effective_at, updated_at'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ async function applyUpsert(entity: string, data: unknown) {
|
||||
case 'closed_days': await db.closed_days.put(data as any); break;
|
||||
case 'closed_weeks': await db.closed_weeks.put(data as any); break;
|
||||
case 'settings_history': await db.settings_history.put(data as any); break;
|
||||
case 'balance_adjustments': await db.balance_adjustments.put(data as any); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +76,7 @@ async function applyDelete(entity: string, id: string) {
|
||||
case 'entries': await db.entries.delete(id); break;
|
||||
case 'closed_days': await db.closed_days.delete(id); break;
|
||||
case 'closed_weeks': await db.closed_weeks.delete(id); break;
|
||||
case 'balance_adjustments': await db.balance_adjustments.delete(id); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dayCapabilities, weekDayKeys, isWorkday } from './utils';
|
||||
import { dayCapabilities, weekDayKeys, isWorkday, composeDeltaMs, decomposeDeltaMs } from './utils';
|
||||
|
||||
describe('dayCapabilities', () => {
|
||||
const today = '2026-04-30';
|
||||
@@ -82,3 +82,35 @@ describe('isWorkday', () => {
|
||||
it('Saturday is not a workday', () => expect(isWorkday('2026-05-02', monToFri)).toBe(false));
|
||||
it('Sunday is not a workday', () => expect(isWorkday('2026-05-03', monToFri)).toBe(false));
|
||||
});
|
||||
|
||||
describe('composeDeltaMs', () => {
|
||||
it('positive: 2h 30m → +9000000', () => {
|
||||
expect(composeDeltaMs('+', 2, 30)).toBe(9_000_000);
|
||||
});
|
||||
it('negative: 1h 15m → -4500000', () => {
|
||||
expect(composeDeltaMs('-', 1, 15)).toBe(-4_500_000);
|
||||
});
|
||||
it('zero hours and minutes → 0', () => {
|
||||
expect(composeDeltaMs('+', 0, 0)).toBe(0);
|
||||
});
|
||||
it('negative zero → 0 (no negative zero)', () => {
|
||||
expect(composeDeltaMs('-', 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decomposeDeltaMs', () => {
|
||||
it('positive 3h 24m', () => {
|
||||
expect(decomposeDeltaMs(12_240_000)).toEqual({ sign: '+', hours: 3, minutes: 24 });
|
||||
});
|
||||
it('negative 1h 30m', () => {
|
||||
expect(decomposeDeltaMs(-5_400_000)).toEqual({ sign: '-', hours: 1, minutes: 30 });
|
||||
});
|
||||
it('zero', () => {
|
||||
expect(decomposeDeltaMs(0)).toEqual({ sign: '+', hours: 0, minutes: 0 });
|
||||
});
|
||||
it('round-trips through compose', () => {
|
||||
const original = -7_320_000; // -2h 2m
|
||||
const { sign, hours, minutes } = decomposeDeltaMs(original);
|
||||
expect(composeDeltaMs(sign, hours, minutes)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,28 @@ export function formatDelta(ms: number): string {
|
||||
return `${sign}${formatDurationShort(Math.abs(ms))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a signed delta_ms from UI inputs.
|
||||
* sign: '+' for credit, '-' for debit.
|
||||
* hours and minutes must be non-negative integers.
|
||||
*/
|
||||
export function composeDeltaMs(sign: '+' | '-', hours: number, minutes: number): number {
|
||||
const magnitude = (Math.floor(hours) * 3_600_000) + (Math.floor(minutes) * 60_000);
|
||||
if (magnitude === 0) return 0;
|
||||
return sign === '-' ? -magnitude : magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompose a signed delta_ms into UI-friendly parts.
|
||||
* Returns sign, whole hours, and remaining minutes (0–59).
|
||||
*/
|
||||
export function decomposeDeltaMs(ms: number): { sign: '+' | '-'; hours: number; minutes: number } {
|
||||
const sign: '+' | '-' = ms < 0 ? '-' : '+';
|
||||
const abs = Math.abs(ms);
|
||||
const totalMinutes = Math.floor(abs / 60_000);
|
||||
return { sign, hours: Math.floor(totalMinutes / 60), minutes: totalMinutes % 60 };
|
||||
}
|
||||
|
||||
/** Parse "HH:MM" time string on a given dayKey (YYYY-MM-DD) into unix ms (local time). */
|
||||
export function parseTimeInput(dayKey: string, hhmm: string): number | null {
|
||||
if (!/^\d{2}:\d{2}$/.test(hhmm)) return null;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { days, weeks, type ClosedDay, type ClosedWeek, ApiError } from '$lib/api/client';
|
||||
import { formatDurationShort, formatDelta } from '$lib/utils';
|
||||
import {
|
||||
days, weeks, balance as balanceApi,
|
||||
type ClosedDay, type ClosedWeek, type BalanceAdjustment, type BalanceSummary,
|
||||
ApiError
|
||||
} from '$lib/api/client';
|
||||
import { formatDurationShort, formatDelta, composeDeltaMs, decomposeDeltaMs, todayKey } from '$lib/utils';
|
||||
|
||||
// Show last 12 weeks
|
||||
function pastWeekKeys(n: number): string[] {
|
||||
@@ -22,29 +26,153 @@
|
||||
let weekKeys = pastWeekKeys(12);
|
||||
let closedWeeksMap: Record<string, ClosedWeek> = $state({});
|
||||
let closedDaysMap: Record<string, ClosedDay> = $state({});
|
||||
let balance: { total_delta_ms: number; closed_week_count: number } | null = $state(null);
|
||||
let summary: BalanceSummary | null = $state(null);
|
||||
let adjustments: BalanceAdjustment[] = $state([]);
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
// ── Add form state ────────────────────────────────────────────────────────
|
||||
let showAddForm = $state(false);
|
||||
let addSign: '+' | '-' = $state('+');
|
||||
let addHours = $state(0);
|
||||
let addMinutes = $state(0);
|
||||
let addNote = $state('');
|
||||
let addEffectiveAt = $state(todayKey());
|
||||
let addError = $state('');
|
||||
let addSaving = $state(false);
|
||||
|
||||
// ── Edit form state ───────────────────────────────────────────────────────
|
||||
let editingId: string | null = $state(null);
|
||||
let editSign: '+' | '-' = $state('+');
|
||||
let editHours = $state(0);
|
||||
let editMinutes = $state(0);
|
||||
let editNote = $state('');
|
||||
let editEffectiveAt = $state('');
|
||||
let editError = $state('');
|
||||
let editSaving = $state(false);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const from = weekKeys[0];
|
||||
const to = weekKeys[weekKeys.length - 1];
|
||||
const [ws, ds, bal] = await Promise.all([
|
||||
const [ws, ds, sum, adjs] = await Promise.all([
|
||||
weeks.list(from, to),
|
||||
days.list('2000-01-01', '2100-01-01'),
|
||||
weeks.balance()
|
||||
weeks.balance(),
|
||||
balanceApi.list()
|
||||
]);
|
||||
closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w]));
|
||||
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
|
||||
balance = bal;
|
||||
summary = sum;
|
||||
adjustments = adjs ?? [];
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function msToDateInput(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateInputToMs(dateStr: string): number {
|
||||
return new Date(dateStr + 'T00:00:00').getTime();
|
||||
}
|
||||
|
||||
// ── Add ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function openAddForm() {
|
||||
showAddForm = true;
|
||||
addSign = '+';
|
||||
addHours = 0;
|
||||
addMinutes = 0;
|
||||
addNote = '';
|
||||
addEffectiveAt = todayKey();
|
||||
addError = '';
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
showAddForm = false;
|
||||
addError = '';
|
||||
}
|
||||
|
||||
async function saveAdd() {
|
||||
addError = '';
|
||||
const deltaMs = composeDeltaMs(addSign, addHours, addMinutes);
|
||||
if (deltaMs === 0) { addError = 'Delta must be non-zero.'; return; }
|
||||
addSaving = true;
|
||||
try {
|
||||
await balanceApi.create({
|
||||
delta_ms: deltaMs,
|
||||
note: addNote,
|
||||
effective_at: dateInputToMs(addEffectiveAt)
|
||||
});
|
||||
showAddForm = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
addError = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
addSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function startEdit(a: BalanceAdjustment) {
|
||||
const { sign, hours, minutes } = decomposeDeltaMs(a.delta_ms);
|
||||
editingId = a.id;
|
||||
editSign = sign;
|
||||
editHours = hours;
|
||||
editMinutes = minutes;
|
||||
editNote = a.note;
|
||||
editEffectiveAt = msToDateInput(a.effective_at);
|
||||
editError = '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editError = '';
|
||||
}
|
||||
|
||||
async function saveEdit(id: string) {
|
||||
editError = '';
|
||||
const deltaMs = composeDeltaMs(editSign, editHours, editMinutes);
|
||||
if (deltaMs === 0) { editError = 'Delta must be non-zero.'; return; }
|
||||
editSaving = true;
|
||||
try {
|
||||
await balanceApi.update(id, {
|
||||
delta_ms: deltaMs,
|
||||
note: editNote,
|
||||
effective_at: dateInputToMs(editEffectiveAt)
|
||||
});
|
||||
editingId = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
editError = e instanceof ApiError ? e.message : String(e);
|
||||
} finally {
|
||||
editSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteAdj(id: string) {
|
||||
if (!confirm('Delete this adjustment?')) return;
|
||||
error = '';
|
||||
try {
|
||||
await balanceApi.delete(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="history">
|
||||
@@ -52,19 +180,26 @@
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
{#if loading}<p>Loading…</p>{/if}
|
||||
|
||||
<!-- Overall balance card -->
|
||||
<!-- Balance card -->
|
||||
<div class="balance-card">
|
||||
<span class="balance-label">Overall balance</span>
|
||||
{#if balance !== null}
|
||||
<span class="balance-value" class:positive={balance.total_delta_ms > 0} class:negative={balance.total_delta_ms < 0}>
|
||||
{formatDelta(balance.total_delta_ms)}
|
||||
{#if summary !== null}
|
||||
<span class="balance-value" class:positive={summary.total_delta_ms > 0} class:negative={summary.total_delta_ms < 0}>
|
||||
{formatDelta(summary.total_delta_ms)}
|
||||
</span>
|
||||
<span class="balance-meta">
|
||||
across {summary.closed_week_count} closed {summary.closed_week_count === 1 ? 'week' : 'weeks'}
|
||||
{#if summary.adjustment_count > 0}
|
||||
· {summary.adjustment_count} {summary.adjustment_count === 1 ? 'adjustment' : 'adjustments'}
|
||||
(weeks {formatDelta(summary.weeks_delta_ms)}, adj {formatDelta(summary.adjustments_delta_ms)})
|
||||
{/if}
|
||||
</span>
|
||||
<span class="balance-meta">across {balance.closed_week_count} closed {balance.closed_week_count === 1 ? 'week' : 'weeks'}</span>
|
||||
{:else}
|
||||
<span class="balance-value">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Closed weeks table -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -91,6 +226,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Recent Days -->
|
||||
<h2>Recent Days</h2>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -110,53 +246,185 @@
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Balance adjustments -->
|
||||
<div class="section-header">
|
||||
<h2>Balance adjustments</h2>
|
||||
{#if !showAddForm}
|
||||
<button class="btn-add" onclick={openAddForm}>+ Add adjustment</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="adj-form card">
|
||||
{#if addError}<p class="form-error">{addError}</p>{/if}
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>Sign</span>
|
||||
<select bind:value={addSign}>
|
||||
<option value="+">+ Credit</option>
|
||||
<option value="-">− Debit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Hours</span>
|
||||
<input type="number" min="0" step="1" bind:value={addHours} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Minutes</span>
|
||||
<input type="number" min="0" max="59" step="1" bind:value={addMinutes} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Effective date</span>
|
||||
<input type="date" bind:value={addEffectiveAt} />
|
||||
</label>
|
||||
<label class="field wide">
|
||||
<span>Note (optional)</span>
|
||||
<input type="text" placeholder="Reason…" bind:value={addNote} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn save" onclick={saveAdd} disabled={addSaving}>Save</button>
|
||||
<button class="btn cancel" onclick={cancelAdd}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adjustments.length > 0}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Effective</th>
|
||||
<th>Delta</th>
|
||||
<th>Note</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each adjustments as a (a.id)}
|
||||
{#if editingId === a.id}
|
||||
<tr>
|
||||
<td colspan="4" class="edit-cell">
|
||||
{#if editError}<p class="form-error">{editError}</p>{/if}
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>Sign</span>
|
||||
<select bind:value={editSign}>
|
||||
<option value="+">+ Credit</option>
|
||||
<option value="-">− Debit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Hours</span>
|
||||
<input type="number" min="0" step="1" bind:value={editHours} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Minutes</span>
|
||||
<input type="number" min="0" max="59" step="1" bind:value={editMinutes} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Effective date</span>
|
||||
<input type="date" bind:value={editEffectiveAt} />
|
||||
</label>
|
||||
<label class="field wide">
|
||||
<span>Note (optional)</span>
|
||||
<input type="text" placeholder="Reason…" bind:value={editNote} />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn save" onclick={() => saveEdit(a.id)} disabled={editSaving}>Save</button>
|
||||
<button class="btn cancel" onclick={cancelEdit}>Cancel</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td>{msToDateInput(a.effective_at)}</td>
|
||||
<td class:positive={a.delta_ms > 0} class:negative={a.delta_ms < 0}>{formatDelta(a.delta_ms)}</td>
|
||||
<td class="note-cell">{a.note}</td>
|
||||
<td class="row-actions">
|
||||
<button class="action-btn" onclick={() => startEdit(a)} title="Edit">✎</button>
|
||||
<button class="action-btn delete" onclick={() => deleteAdj(a.id)} title="Delete">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else if !showAddForm}
|
||||
<p class="empty">No adjustments yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h1 { margin: 0 0 1rem; }
|
||||
h2 { margin: 2rem 0 0.5rem; }
|
||||
h2 { margin: 0; font-size: 1rem; color: #495057; }
|
||||
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
|
||||
|
||||
/* Balance card */
|
||||
.balance-card {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.balance-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #6c757d;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
|
||||
background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
padding: 1rem 1.25rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.balance-label { font-size: 0.85rem; color: #6c757d; font-weight: 500; white-space: nowrap; }
|
||||
.balance-value { font-size: 1.5rem; font-weight: 700; color: #6c757d; letter-spacing: -0.5px; }
|
||||
.balance-value.positive { color: #27ae60; }
|
||||
.balance-value.negative { color: #c0392b; }
|
||||
.balance-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #adb5bd;
|
||||
}
|
||||
.balance-meta { font-size: 0.8rem; color: #adb5bd; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
|
||||
th { background: #f8f9fa; padding: 0.6rem 1rem; text-align: left; font-size: 0.85rem; color: #6c757d; border-bottom: 1px solid #dee2e6; }
|
||||
td { padding: 0.5rem 1rem; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
|
||||
/* Tables */
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px;
|
||||
overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
|
||||
th { background: #f8f9fa; padding: 0.6rem 1rem; text-align: left;
|
||||
font-size: 0.85rem; color: #6c757d; border-bottom: 1px solid #dee2e6; }
|
||||
td { padding: 0.5rem 1rem; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; vertical-align: middle; }
|
||||
tr.closed td { color: #495057; }
|
||||
.positive { color: #27ae60; font-weight: 600; }
|
||||
.negative { color: #c0392b; font-weight: 600; }
|
||||
.note-cell { color: #6c757d; font-style: italic; }
|
||||
|
||||
/* Section header */
|
||||
.section-header { display: flex; align-items: center; justify-content: space-between;
|
||||
margin: 2rem 0 0.75rem; }
|
||||
.btn-add { padding: 0.35rem 0.9rem; background: #2c7be5; color: #fff;
|
||||
border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; }
|
||||
|
||||
/* Adjustment form */
|
||||
.adj-form { padding: 1rem 1.25rem; margin-bottom: 1rem; }
|
||||
.card { background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.form-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; font-weight: 500; }
|
||||
.field.wide { flex: 1; min-width: 180px; }
|
||||
.field input, .field select {
|
||||
padding: 0.35rem 0.6rem; border: 1px solid #ced4da; border-radius: 6px;
|
||||
font-size: 0.9rem; background: #fff;
|
||||
}
|
||||
.field input[type="number"] { width: 5rem; }
|
||||
.form-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.btn { padding: 0.4rem 1rem; border: none; border-radius: 6px;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer; }
|
||||
.btn.save { background: #2c7be5; color: #fff; }
|
||||
.btn.save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn.cancel { background: #e9ecef; color: #343a40; }
|
||||
.form-error { color: #c0392b; font-size: 0.85rem; margin: 0 0 0.5rem; }
|
||||
|
||||
/* Edit cell */
|
||||
.edit-cell { background: #f8f9fa; padding: 0.75rem 1rem; }
|
||||
|
||||
/* Row actions */
|
||||
.row-actions { display: flex; gap: 0.25rem; white-space: nowrap; }
|
||||
.action-btn { background: none; border: none; cursor: pointer; padding: 0.2rem 0.35rem;
|
||||
border-radius: 4px; font-size: 0.95rem; color: #adb5bd; transition: color 0.15s; }
|
||||
.action-btn:hover { color: #2c7be5; }
|
||||
.action-btn.delete:hover { color: #e74c3c; }
|
||||
|
||||
/* Misc */
|
||||
.empty { color: #adb5bd; font-size: 0.9rem; margin: 0.5rem 0 1.5rem; }
|
||||
|
||||
.badge { font-size: 0.75rem; padding: 0.15rem 0.4rem; border-radius: 4px; }
|
||||
.badge[data-kind="work"] { background: #d4edda; color: #155724; }
|
||||
.badge[data-kind="holiday"] { background: #fff3cd; color: #856404; }
|
||||
.badge[data-kind="vacation"] { background: #cce5ff; color: #004085; }
|
||||
.badge[data-kind="sick"] { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user