Files
wotra/internal/domain/domain.go
Andreas Schneider d8366f5c25 Add sync redesign with offline fallback (M9)
- Migration 003: adds logged_at to sync_log for TTL pruning; migrates
  settings_history to UUID TEXT PK with updated_at column
- SyncStore: Prune() deletes rows older than 30d and writes a '_pruned'
  marker at the boundary version; Pull() calls Prune lazily and returns
  ErrSyncStale (410) when the client's since_version is behind the marker
- sync_handler.go: GET /api/sync/pull?since=N; POST /api/sync/push with
  last-updated_at-wins conflict resolution for entries, balance_adjustments,
  settings_history; closed_days/closed_weeks skipped (server-only mutations)
- router.go: passes entryStore, adjustmentStore, settingsStore to SyncHandler
- settings_store.go: UUID PK, updated_at column, Upsert() for push path
- settings_service.go: generates UUID on create, sets updated_at on update
- settings_handler.go: ID params changed from int64 to string
- domain.go: Settings.ID string, Settings.UpdatedAt added
- client.ts: all mutation methods catch TypeError (offline) and fall back
  to Dexie write + outbox enqueue; crypto.randomUUID() for offline creates;
  Settings.id type changed to string
- db.ts: Dexie v3 — settings_history key path changed to string UUID;
  upgrade handler clears table for repopulation via pull
- sync.ts: real pushOutbox to POST /api/sync/push; pullChanges uses GET
  with ?since=N; 410 triggers coldStart() + retry; coldStart() wipes all
  tables and resets last_version
- 4 new Go store tests covering normal pull, stale client, empty prune,
  client-ahead-of-marker; all tests pass (store + service, 19 Vitest)
2026-04-30 22:50:33 +02:00

133 lines
3.7 KiB
Go

package domain
// DayKind represents the kind of a closed day.
type DayKind string
const (
DayKindWork DayKind = "work"
DayKindHoliday DayKind = "holiday"
DayKindVacation DayKind = "vacation"
DayKindSick DayKind = "sick"
)
func (k DayKind) Valid() bool {
switch k {
case DayKindWork, DayKindHoliday, DayKindVacation, DayKindSick:
return true
}
return false
}
// Entry is a tracked time range within a single day.
type Entry struct {
ID string `json:"id"`
StartTime int64 `json:"start_time"` // unix ms UTC
EndTime *int64 `json:"end_time"` // nil while running
AutoStopped bool `json:"auto_stopped"`
Note string `json:"note"`
DayKey string `json:"day_key"` // YYYY-MM-DD in configured tz
UpdatedAt int64 `json:"updated_at"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
}
// IsRunning returns true if the entry has no end time.
func (e *Entry) IsRunning() bool {
return e.EndTime == nil
}
// DurationMs returns the duration in milliseconds. Returns 0 for running entries.
func (e *Entry) DurationMs() int64 {
if e.EndTime == nil {
return 0
}
return *e.EndTime - e.StartTime
}
// ClosedDay is the merged result of closing a day.
type ClosedDay struct {
DayKey string `json:"day_key"`
StartTime *int64 `json:"start_time"` // nil for non-work kinds
EndTime *int64 `json:"end_time"` // nil for non-work kinds
WorkedMs int64 `json:"worked_ms"`
Kind DayKind `json:"kind"`
ClosedAt int64 `json:"closed_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ClosedWeek is the overtime/undertime snapshot for a week.
type ClosedWeek struct {
WeekKey string `json:"week_key"` // YYYY-Www ISO week
ExpectedMs int64 `json:"expected_ms"`
WorkedMs int64 `json:"worked_ms"`
DeltaMs int64 `json:"delta_ms"` // worked - expected (signed)
ClosedAt int64 `json:"closed_at"`
UpdatedAt int64 `json:"updated_at"`
}
// Settings holds the effective configuration for a period.
type Settings struct {
ID string `json:"id"`
EffectiveFrom string `json:"effective_from"` // YYYY-MM-DD
HoursPerWeek float64 `json:"hours_per_week"`
WorkdaysMask int `json:"workdays_mask"` // bits Mon=1..Sun=64
Timezone string `json:"timezone"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// DailyExpectedMs returns the expected milliseconds for a single workday.
func (s *Settings) DailyExpectedMs() int64 {
days := popcount(s.WorkdaysMask)
if days == 0 {
return 0
}
totalMs := int64(s.HoursPerWeek * 3_600_000)
return totalMs / int64(days)
}
// IsWorkday returns true if the given weekday (0=Sunday..6=Saturday, time.Weekday) is a workday.
// We use Mon=bit0 .. Sun=bit6 internally.
func (s *Settings) IsWorkday(wd int) bool {
// time.Weekday: Sunday=0, Monday=1, ..., Saturday=6
// our mask: Monday=bit0(1), Tuesday=bit1(2), ... Sunday=bit6(64)
var bit int
switch wd {
case 0: // Sunday
bit = 64
case 1: // Monday
bit = 1
case 2:
bit = 2
case 3:
bit = 4
case 4:
bit = 8
case 5:
bit = 16
case 6: // Saturday
bit = 32
}
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 {
count += n & 1
n >>= 1
}
return count
}