feat(hardcover): add per-book sync controls and book sync scope

Introduce a per-book sync toggle in the book detail view that lets
users include or exclude individual books from Hardcover sync.

Add a book sync scope setting with two modes:
- all_eligible: sync all books unless explicitly excluded (default)
- selected_only: sync only books the user explicitly includes

Extract sync eligibility logic into hardcover-sync-policy.ts for
testability. The toggle handles unread books correctly: sync is skipped
while the book is unread, but the user's inclusion intent is stored so
sync activates automatically once reading starts.

Closes #412
This commit is contained in:
neon
2026-06-23 22:37:54 -06:00
parent e7956dadee
commit 8f79e92053
31 changed files with 13019 additions and 63 deletions
+30
View File
@@ -1,10 +1,20 @@
export type HardcoverSyncDisabledReason = "permission_denied" | "missing_token" | "user_disabled";
export type HardcoverBookSyncMode = "all_eligible" | "selected_only";
export type HardcoverBookSyncOverride = "included" | "excluded" | null;
export type HardcoverBookSyncEffectiveReason =
| HardcoverSyncDisabledReason
| "global_disabled"
| "not_selected"
| "excluded"
| "unread"
| "unsupported_status";
export interface HardcoverSettings {
tokenConfigured: boolean;
enabled: boolean;
effectiveEnabled: boolean;
disabledReason: HardcoverSyncDisabledReason | null;
bookSyncMode: HardcoverBookSyncMode;
autoSyncOnStatusChange: boolean;
autoSyncOnProgressUpdate: boolean;
autoSyncOnRatingChange: boolean;
@@ -15,6 +25,7 @@ export interface HardcoverSettings {
export interface UpsertHardcoverSettingsPayload {
apiToken?: string;
enabled?: boolean;
bookSyncMode?: HardcoverBookSyncMode;
autoSyncOnStatusChange?: boolean;
autoSyncOnProgressUpdate?: boolean;
autoSyncOnRatingChange?: boolean;
@@ -33,6 +44,25 @@ export interface HardcoverSyncPendingSummary {
pendingBooks: number;
}
export interface HardcoverBookSyncState {
bookId: number;
syncOverride: HardcoverBookSyncOverride;
syncEnabled: boolean;
canSyncNow: boolean;
effectiveReason: HardcoverBookSyncEffectiveReason | null;
lastSyncedAt: string | null;
syncError: string | null;
}
export interface UpdateHardcoverBookSyncPayload {
syncEnabled: boolean;
}
export interface HardcoverBookSyncNowResult {
result: "synced" | "skipped" | "failed";
state: HardcoverBookSyncState;
}
export interface HardcoverActiveSyncStatus {
runId: number;
syncedBooks: number;