markRead() fired its PUT fire-and-forget with no retry and silently
swallowed failures. If one PUT among a burst (paging through article
view fires one per page turn) failed or lost a race with the app's own
background sync()->fetchData() refetch, that article's read-mark was
permanently lost: the header's unreadCount (derived live from
allItems) would show it again mid-session, and a reload reproduced it
identically since the DB genuinely still had it unread.
- markRead() now retries transient failures (no response / 5xx / 429)
with backoff and a request timeout, skips retrying non-retryable
4xx, and surfaces a message on final failure instead of only
console.log.
- A pendingReadCounts refcount (keyed by article id) tracks in-flight/
retrying marks; fetchData() excludes those ids - snapshotting before
its GET is dispatched, not just once the response lands - so a
concurrent refetch can't resurrect an article whose read-confirmation
is still in flight or unsettled at request time.
- markAllRead() now reports a partial-failure summary instead of
unconditionally claiming full success when some marks fail.
Extends vue/src/composables/__tests__/useFeeds.spec.js with coverage
for retry/backoff, non-retryable errors, the dispatch-before-response
race, overlapping concurrent marks for the same id, and markAllRead's
partial-failure message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVw1XNQjodp4B7qiZqjtDE
A sync button press racing a page-reload sync could run two sync
requests concurrently. create_feed_item used a check-then-insert
pattern (query for an existing item, then insert if none found), so
both requests could pass the check before either inserted, creating
duplicate items.
- Add a UNIQUE (feed_id, url) constraint on feed_item, keyed on the
article's link rather than its title since that's RSS's stable
article identity (a feed editing a headline after publishing, same
link, would otherwise dupe under title-based matching).
- The migration first collapses any duplicates already created by the
race, propagating read=true onto the surviving row when any
duplicate in its group was already read, so the cleanup can't make
an already-read article look unread.
- create_feed_item now does a single atomic
INSERT ... ON CONFLICT (feed_id, url) DO NOTHING instead of
select-then-insert, closing the race entirely.
- Add a test that races two threads on separate connections inserting
the same item and asserts only one row survives.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.
Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On real Android touchscreens, a horizontal drag on content with nothing
left to scroll horizontally gets claimed by Chrome's native
swipe-to-go-back/forward gesture before our own touchend listener
(article-view swipe navigation) ever sees it — silently, with no
touchend firing at all. Synthetic touch testing (mouse-drag emulation
in devtools) doesn't go through that gesture recognizer, so it worked
there while doing nothing on an actual phone.
Fix: overscroll-behavior-x: contain on body stops Chrome from claiming
the gesture for its own navigation, letting our touchend fire normally.
Also drops the overflow-x: hidden added to .article-single for the
slide transition — body already has it (for the same full-bleed-image
reason), so it was redundant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Swiping left/right in the single-article view now pages to the
next/previous article, same as the up/down nav buttons. Touch handlers
live on .article-single: a swipe needs >=50px horizontal travel and
<=75px vertical travel to count (so scrolling/diagonal drags don't
trigger it), and multi-touch or swipes starting inside a horizontally
scrollable <pre> block are ignored so code blocks keep their own
scroll behavior.
Also adds a direction-aware slide/fade transition (via Vue's
<Transition>, keyed on currentIndex) when paging between articles,
triggered by both the swipe gesture and the nav buttons.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>