feat(sync): add ProgressService with merge, enrichment, and conflict detection

Introduces a centralized ProgressService that handles all reading progress
writes across web, KOReader, and Kobo clients. The service implements:

- Merge strategy: reads existing progress first, then only overwrites
  non-nil fields from the incoming request. This fixes the data loss bug
  where partial updates (e.g., Kobo last-read-place sending only epubcfi
  and chapter) would NULL out percentage, character_offset, etc.

- Enrichment: computes missing fields from available data:
  - character_offset from percentage + total_characters
  - current_page from percentage + total_pages
  - percentage from current_page + total_pages (reverse)
  - percentage from character_offset + total_characters (reverse)

- Conflict detection: when a different source writes progress within 5
  minutes with >1% difference, records a sync_conflicts row and broadcasts
  a WebSocket notification for real-time UI alerts.

- Broadcast control: SaveProgressRequest.Broadcast flag lets Kobo
  last-read-place and SyncFromServer skip WebSocket broadcasts.

- Pointer fields on SaveProgressRequest: nil means preserve existing,
  non-nil means overwrite. Eliminates ambiguity between zero values
  and not-provided fields.

Also adds unit tests for buildProgressSnapshot helper function.
This commit is contained in:
2026-04-25 21:16:15 -04:00
parent e87f481988
commit f699899408
2 changed files with 329 additions and 0 deletions
+40
View File
@@ -456,3 +456,43 @@ func TestEdgeCases(t *testing.T) {
assert.InDelta(t, 100, page, 1)
})
}
func TestBuildProgressSnapshot(t *testing.T) {
t.Run("nil fields are omitted", func(t *testing.T) {
pct := 0.5
ch := 3
req := SaveProgressRequest{
Percentage: &pct,
Chapter: &ch,
}
snapshot := buildProgressSnapshot(req)
assert.InDelta(t, 0.5, snapshot["percentage"], 0.001)
assert.Equal(t, 3, snapshot["chapter"])
_, hasEpubcfi := snapshot["epubcfi"]
assert.False(t, hasEpubcfi)
})
t.Run("all fields present", func(t *testing.T) {
pct := 0.75
cfi := "epubcfi(/6/4/2:10)"
ch := 5
charOff := int64(10000)
page := 150
tp := 200
req := SaveProgressRequest{
Percentage: &pct,
Epubcfi: &cfi,
Chapter: &ch,
CharacterOffset: &charOff,
CurrentPage: &page,
TotalPages: &tp,
}
snapshot := buildProgressSnapshot(req)
assert.InDelta(t, 0.75, snapshot["percentage"], 0.001)
assert.Equal(t, "epubcfi(/6/4/2:10)", snapshot["epubcfi"])
assert.Equal(t, 5, snapshot["chapter"])
assert.Equal(t, int64(10000), snapshot["character"])
assert.Equal(t, 150, snapshot["page"])
assert.Equal(t, 200, snapshot["total_pages"])
})
}