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

31
PLAN.md
View File

@@ -88,13 +88,25 @@ CREATE TABLE settings_history (
-- Sync event log (last-write-wins per entity row) -- Sync event log (last-write-wins per entity row)
CREATE TABLE sync_log ( 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, entity_id TEXT NOT NULL,
op TEXT NOT NULL, -- 'upsert' | 'delete' op TEXT NOT NULL, -- 'upsert' | 'delete'
version INTEGER NOT NULL, -- monotonic server-assigned version INTEGER NOT NULL, -- monotonic server-assigned
payload TEXT NOT NULL, -- JSON snapshot payload TEXT NOT NULL, -- JSON snapshot
PRIMARY KEY (entity, entity_id, version) 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) ### 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 GET /api/weeks?from=YYYY-Www&to=YYYY-Www
POST /api/weeks/{week_key}/close -> ClosedWeek POST /api/weeks/{week_key}/close -> ClosedWeek
DELETE /api/weeks/{week_key}/close (reopen week) 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 # Settings
GET /api/settings # current effective settings GET /api/settings # current effective settings
@@ -243,7 +262,11 @@ Staged implementation:
| 4 | Backend guard: reject future-day intervals | pending | | 4 | Backend guard: reject future-day intervals | pending |
| 5 | Bottom nav Today shortcut; delete /today route | 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. CSV/JSON export, monthly summary view.
## 7. Decisions & Rationale ## 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 | | 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) | | 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 | | 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 | | Test framework | Vitest (frontend) + Go testing (backend) | Automated coverage for capability logic and key utilities |

View File

@@ -42,13 +42,14 @@ func main() {
entryStore := store.NewEntryStore(db) entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db) closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db) closedWeekStore := store.NewClosedWeekStore(db)
adjustmentStore := store.NewBalanceAdjustmentStore(db)
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
syncStore := store.NewSyncStore(db) syncStore := store.NewSyncStore(db)
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz) daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, settingsStore, tz)
settingsSvc := service.NewSettingsService(settingsStore) 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 // Background goroutine: auto-stop entries that cross midnight
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())

View File

@@ -109,6 +109,18 @@ func (s *Settings) IsWorkday(wd int) bool {
return s.WorkdaysMask&bit != 0 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 { func popcount(n int) int {
count := 0 count := 0
for n != 0 { for n != 0 {

View File

@@ -1,14 +1,18 @@
package handler package handler
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/wotra/wotra/internal/domain"
"github.com/wotra/wotra/internal/service" "github.com/wotra/wotra/internal/service"
) )
// WeekHandler serves /api/weeks routes. // WeekHandler serves /api/weeks and /api/balance/adjustments routes.
type WeekHandler struct { type WeekHandler struct {
svc *service.WeekService svc *service.WeekService
} }
@@ -22,6 +26,11 @@ func (h *WeekHandler) Routes(r chi.Router) {
r.Get("/weeks/balance", h.Balance) r.Get("/weeks/balance", h.Balance)
r.Post("/weeks/{week_key}/close", h.Close) r.Post("/weeks/{week_key}/close", h.Close)
r.Delete("/weeks/{week_key}/close", h.Reopen) 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 // Balance GET /api/weeks/balance
@@ -90,3 +99,107 @@ func (h *WeekHandler) Reopen(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) 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)
}

View File

@@ -11,10 +11,18 @@ import (
"github.com/wotra/wotra/internal/store" "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. // WeekService handles closing weeks and computing overtime/undertime.
type WeekService struct { type WeekService struct {
closedDays *store.ClosedDayStore closedDays *store.ClosedDayStore
closedWeeks *store.ClosedWeekStore closedWeeks *store.ClosedWeekStore
adjustments *store.BalanceAdjustmentStore
syncStore *store.SyncStore
entries *store.EntryStore entries *store.EntryStore
settings *store.SettingsStore settings *store.SettingsStore
db interface { db interface {
@@ -29,12 +37,16 @@ func NewWeekService(
closedWeeks *store.ClosedWeekStore, closedWeeks *store.ClosedWeekStore,
entries *store.EntryStore, entries *store.EntryStore,
settings *store.SettingsStore, settings *store.SettingsStore,
adjustments *store.BalanceAdjustmentStore,
syncStore *store.SyncStore,
rawDB *sql.DB, rawDB *sql.DB,
tz *time.Location, tz *time.Location,
) *WeekService { ) *WeekService {
return &WeekService{ return &WeekService{
closedDays: closedDays, closedDays: closedDays,
closedWeeks: closedWeeks, closedWeeks: closedWeeks,
adjustments: adjustments,
syncStore: syncStore,
entries: entries, entries: entries,
settings: settings, settings: settings,
rawDB: rawDB, rawDB: rawDB,
@@ -45,16 +57,77 @@ func NewWeekService(
// BalanceResult holds the overall overtime/undertime balance across all closed weeks. // BalanceResult holds the overall overtime/undertime balance across all closed weeks.
type BalanceResult struct { type BalanceResult struct {
TotalDeltaMs int64 `json:"total_delta_ms"` TotalDeltaMs int64 `json:"total_delta_ms"`
WeeksDeltaMs int64 `json:"weeks_delta_ms"`
AdjustmentsDeltaMs int64 `json:"adjustments_delta_ms"`
ClosedWeekCount int `json:"closed_week_count"` 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) { 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 { if err != nil {
return BalanceResult{}, err 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. // WeekDayKeysExported is exported for testing.

View File

@@ -22,12 +22,14 @@ func newFullServices(t *testing.T) (*service.EntryService, *service.DayService,
entryStore := store.NewEntryStore(db) entryStore := store.NewEntryStore(db)
closedDayStore := store.NewClosedDayStore(db) closedDayStore := store.NewClosedDayStore(db)
closedWeekStore := store.NewClosedWeekStore(db) closedWeekStore := store.NewClosedWeekStore(db)
adjustmentStore := store.NewBalanceAdjustmentStore(db)
syncStore := store.NewSyncStore(db)
settingsStore := store.NewSettingsStore(db) settingsStore := store.NewSettingsStore(db)
tz, _ := time.LoadLocation("UTC") tz, _ := time.LoadLocation("UTC")
entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz) entrySvc := service.NewEntryService(entryStore, closedDayStore, settingsStore, tz)
daySvc := service.NewDayService(entryStore, closedDayStore, closedWeekStore, 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) settingsSvc := service.NewSettingsService(settingsStore)
return entrySvc, daySvc, weekSvc, settingsSvc return entrySvc, daySvc, weekSvc, settingsSvc
} }
@@ -246,13 +248,13 @@ func TestWeekServiceBalance(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, daySvc, weekSvc, _ := newFullServices(t) _, daySvc, weekSvc, _ := newFullServices(t)
// Empty — no closed weeks yet. // Empty — no closed weeks, no adjustments.
bal, err := weekSvc.Balance(ctx) bal, err := weekSvc.Balance(ctx)
if err != nil { if err != nil {
t.Fatalf("Balance (empty): %v", err) t.Fatalf("Balance (empty): %v", err)
} }
if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 { if bal.TotalDeltaMs != 0 || bal.ClosedWeekCount != 0 || bal.AdjustmentCount != 0 {
t.Errorf("empty: want {0,0}, got {%d,%d}", bal.TotalDeltaMs, bal.ClosedWeekCount) t.Errorf("empty: want all zeros, got %+v", bal)
} }
// Close two weeks as holiday (worked = expected → delta = 0 each). // 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) t.Fatalf("Balance (populated): %v", err)
} }
if bal.ClosedWeekCount != 2 { 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 || bal.WeeksDeltaMs != 0 {
if bal.TotalDeltaMs != 0 { t.Errorf("weeks-only total: want 0, got %+v", bal)
t.Errorf("total_delta_ms: want 0, got %d", bal.TotalDeltaMs) }
// 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)
} }
} }

View 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")

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

View File

@@ -3,15 +3,18 @@ package store
import ( import (
"context" "context"
"database/sql" "database/sql"
_ "embed"
"fmt" "fmt"
"io/fs"
"sort"
"strings" "strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"embed"
) )
//go:embed 001_initial.sql //go:embed migrations/*.sql
var schema string var migrationsFS embed.FS
// Open opens (or creates) the SQLite database at path and runs migrations. // Open opens (or creates) the SQLite database at path and runs migrations.
func Open(path string) (*sql.DB, error) { func Open(path string) (*sql.DB, error) {
@@ -31,22 +34,51 @@ func Open(path string) (*sql.DB, error) {
return db, nil return db, nil
} }
// migrate runs embedded SQL migrations. Simple single-file approach: we track // migrate applies all SQL migration files in migrations/ in filename order.
// a user_version pragma and apply the schema once if version == 0. // user_version tracks the last applied migration index (1-based).
func migrate(db *sql.DB) error { func migrate(db *sql.DB) error {
var version int var version int
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
return err 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() ctx := context.Background()
// Strip migration comments and split into individual statements for i, entry := range entries {
stmts := splitStatements(schema) migrationNum := i + 1 // 1-based
for _, stmt := range stmts { 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) stmt = strings.TrimSpace(stmt)
if stmt == "" { if stmt == "" {
continue continue
@@ -55,12 +87,6 @@ func migrate(db *sql.DB) error {
return fmt.Errorf("exec %q: %w", stmt[:min(len(stmt), 60)], err) 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 return nil
} }

View File

@@ -13,7 +13,7 @@ func TestMigration(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer db.Close() 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 { for _, tbl := range tables {
var name string var name string
err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name) err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tbl).Scan(&name)

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

View File

@@ -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)) 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 { func (s *SyncStore) log(ctx context.Context, entity, entityID, op, payload string) error {
version, err := s.nextVersion(ctx) version, err := s.nextVersion(ctx)
if err != nil { if err != nil {

View File

@@ -81,6 +81,23 @@ export interface Settings {
created_at: number; 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 ───────────────────────────────────────────────────────────────── // ─── Entries ─────────────────────────────────────────────────────────────────
export const entries = { export const entries = {
@@ -125,7 +142,18 @@ export const weeks = {
}, },
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`), close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
reopen: (weekKey: string) => request<void>('DELETE', `/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 ──────────────────────────────────────────────────────────────── // ─── Settings ────────────────────────────────────────────────────────────────

View File

@@ -1,9 +1,9 @@
import Dexie, { type Table } from 'dexie'; 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 { export interface OutboxItem {
id?: number; // auto-increment 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; entity_id: string;
op: 'upsert' | 'delete'; op: 'upsert' | 'delete';
payload: string; // JSON payload: string; // JSON
@@ -15,6 +15,7 @@ export class WotraDB extends Dexie {
closed_days!: Table<ClosedDay, string>; closed_days!: Table<ClosedDay, string>;
closed_weeks!: Table<ClosedWeek, string>; closed_weeks!: Table<ClosedWeek, string>;
settings_history!: Table<Settings, number>; settings_history!: Table<Settings, number>;
balance_adjustments!: Table<BalanceAdjustment, string>;
outbox!: Table<OutboxItem, number>; outbox!: Table<OutboxItem, number>;
meta!: Table<{ key: string; value: string }, string>; meta!: Table<{ key: string; value: string }, string>;
@@ -28,6 +29,9 @@ export class WotraDB extends Dexie {
outbox: '++id, entity, entity_id', outbox: '++id, entity, entity_id',
meta: 'key' meta: 'key'
}); });
this.version(2).stores({
balance_adjustments: 'id, effective_at, updated_at'
});
} }
} }

View File

@@ -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_days': await db.closed_days.put(data as any); break;
case 'closed_weeks': await db.closed_weeks.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 '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 'entries': await db.entries.delete(id); break;
case 'closed_days': await db.closed_days.delete(id); break; case 'closed_days': await db.closed_days.delete(id); break;
case 'closed_weeks': await db.closed_weeks.delete(id); break; case 'closed_weeks': await db.closed_weeks.delete(id); break;
case 'balance_adjustments': await db.balance_adjustments.delete(id); break;
} }
} }

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { dayCapabilities, weekDayKeys, isWorkday } from './utils'; import { dayCapabilities, weekDayKeys, isWorkday, composeDeltaMs, decomposeDeltaMs } from './utils';
describe('dayCapabilities', () => { describe('dayCapabilities', () => {
const today = '2026-04-30'; 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('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)); 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);
});
});

View File

@@ -71,6 +71,28 @@ export function formatDelta(ms: number): string {
return `${sign}${formatDurationShort(Math.abs(ms))}`; 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 (059).
*/
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). */ /** 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 { export function parseTimeInput(dayKey: string, hhmm: string): number | null {
if (!/^\d{2}:\d{2}$/.test(hhmm)) return null; if (!/^\d{2}:\d{2}$/.test(hhmm)) return null;

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { days, weeks, type ClosedDay, type ClosedWeek, ApiError } from '$lib/api/client'; import {
import { formatDurationShort, formatDelta } from '$lib/utils'; 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 // Show last 12 weeks
function pastWeekKeys(n: number): string[] { function pastWeekKeys(n: number): string[] {
@@ -22,29 +26,153 @@
let weekKeys = pastWeekKeys(12); let weekKeys = pastWeekKeys(12);
let closedWeeksMap: Record<string, ClosedWeek> = $state({}); let closedWeeksMap: Record<string, ClosedWeek> = $state({});
let closedDaysMap: Record<string, ClosedDay> = $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 error = $state('');
let loading = $state(false); 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; loading = true;
error = '';
try { try {
const from = weekKeys[0]; const from = weekKeys[0];
const to = weekKeys[weekKeys.length - 1]; 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), weeks.list(from, to),
days.list('2000-01-01', '2100-01-01'), days.list('2000-01-01', '2100-01-01'),
weeks.balance() weeks.balance(),
balanceApi.list()
]); ]);
closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w])); closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w]));
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d])); closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
balance = bal; summary = sum;
adjustments = adjs ?? [];
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} finally { } finally {
loading = false; 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> </script>
<div class="history"> <div class="history">
@@ -52,19 +180,26 @@
{#if error}<p class="error">{error}</p>{/if} {#if error}<p class="error">{error}</p>{/if}
{#if loading}<p>Loading…</p>{/if} {#if loading}<p>Loading…</p>{/if}
<!-- Overall balance card --> <!-- Balance card -->
<div class="balance-card"> <div class="balance-card">
<span class="balance-label">Overall balance</span> <span class="balance-label">Overall balance</span>
{#if balance !== null} {#if summary !== null}
<span class="balance-value" class:positive={balance.total_delta_ms > 0} class:negative={balance.total_delta_ms < 0}> <span class="balance-value" class:positive={summary.total_delta_ms > 0} class:negative={summary.total_delta_ms < 0}>
{formatDelta(balance.total_delta_ms)} {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>
<span class="balance-meta">across {balance.closed_week_count} closed {balance.closed_week_count === 1 ? 'week' : 'weeks'}</span>
{:else} {:else}
<span class="balance-value"></span> <span class="balance-value"></span>
{/if} {/if}
</div> </div>
<!-- Closed weeks table -->
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -91,6 +226,7 @@
</tbody> </tbody>
</table> </table>
<!-- Recent Days -->
<h2>Recent Days</h2> <h2>Recent Days</h2>
<table> <table>
<thead> <thead>
@@ -110,53 +246,185 @@
{/each} {/each}
</tbody> </tbody>
</table> </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">&#9998;</button>
<button class="action-btn delete" onclick={() => deleteAdj(a.id)} title="Delete">&#10005;</button>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{:else if !showAddForm}
<p class="empty">No adjustments yet.</p>
{/if}
</div> </div>
<style> <style>
h1 { margin: 0 0 1rem; } 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; } .error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
/* Balance card */ /* Balance card */
.balance-card { .balance-card {
display: flex; display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;
align-items: baseline; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
gap: 0.75rem; padding: 1rem 1.25rem; margin-bottom: 1.25rem;
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-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.positive { color: #27ae60; }
.balance-value.negative { color: #c0392b; } .balance-value.negative { color: #c0392b; }
.balance-meta { .balance-meta { font-size: 0.8rem; color: #adb5bd; }
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; } /* Tables */
th { background: #f8f9fa; padding: 0.6rem 1rem; text-align: left; font-size: 0.85rem; color: #6c757d; border-bottom: 1px solid #dee2e6; } table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px;
td { padding: 0.5rem 1rem; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; } 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; } tr.closed td { color: #495057; }
.positive { color: #27ae60; font-weight: 600; } .positive { color: #27ae60; font-weight: 600; }
.negative { color: #c0392b; 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 { font-size: 0.75rem; padding: 0.15rem 0.4rem; border-radius: 4px; }
.badge[data-kind="work"] { background: #d4edda; color: #155724; } .badge[data-kind="work"] { background: #d4edda; color: #155724; }
.badge[data-kind="holiday"] { background: #fff3cd; color: #856404; } .badge[data-kind="holiday"] { background: #fff3cd; color: #856404; }
.badge[data-kind="vacation"] { background: #cce5ff; color: #004085; } .badge[data-kind="vacation"] { background: #cce5ff; color: #004085; }
.badge[data-kind="sick"] { background: #f8d7da; color: #721c24; } .badge[data-kind="sick"] { background: #f8d7da; color: #721c24; }
</style> </style>