package org.terst.doot.widget.data import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json private val json = Json { ignoreUnknownKeys = true } // How long a locally-optimistic removal is trusted over a possibly-stale // server fetch before it's allowed to expire. Bounded, not permanent: if a // completion never actually confirms (e.g. the device goes offline and // CompleteWorker/DeferWorker retry forever), the item must still eventually // reappear rather than staying hidden forever -- the existing periodic // RefreshWorker tick already relies on exactly this kind of self-healing // (see the "Widget periodic-refresh self-healing fix" worklog entry), and a // non-expiring pending-removal would silently break that property. const val PENDING_REMOVAL_TTL_MS = 2 * 60 * 1000L fun pendingKey(id: String, source: String) = "$id|$source" fun decodePendingRemovals(raw: String?): Map = raw?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } ?: emptyMap() fun encodePendingRemovals(pending: Map): String = json.encodeToString(pending) fun Map.pruneExpired(now: Long): Map = filterValues { now - it < PENDING_REMOVAL_TTL_MS } /** * Excludes items whose (id, source) has an unexpired pending local removal. * * This exists to close a race in fetchAndPersist: it does a full overwrite * of the cached item list on every successful fetch, and CompleteWorker / * DeferWorker run one instance per task with no ordering guarantee between * different tasks' workers. Tapping complete on task A starts a GET that's * still in flight; tapping complete on task B before A's GET returns * optimistically removes B locally -- but A's GET response was captured * before B's completion landed server-side, so if A's slower write lands * after B's, it would silently resurrect B. This is the root cause of the * 2026-08-05 report: "check to complete works once, then doesn't work * anymore" -- it wasn't that later taps failed (server logs confirmed every * completion request succeeded), it's that a slower, earlier-started fetch * kept clobbering faster, later optimistic removals. */ fun List.excludingUnexpiredPendingRemovals(pending: Map, now: Long): List { if (pending.isEmpty()) return this return filterNot { item -> val removedAt = pending[pendingKey(item.id, item.source)] ?: return@filterNot false now - removedAt < PENDING_REMOVAL_TTL_MS } }