Add balance adjustments (M8)

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

View File

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