From c95b7602940d68b1661a1f970a2d884fd3ca4b45 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 21:12:18 -0400 Subject: [PATCH] feat(posts): in-context anchored feed with bidirectional infinite scroll Provenance "View post" deep-links to /posts?post_id=X, which now opens the feed centered on that post with infinite load in BOTH directions. Backend: PostFeedService.scroll gains a direction (older|newer); new around(post_id) returns a window of newer + the post + older with a cursor for each end. /api/posts accepts ?around= and ?direction=. + API tests. Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends, newer prepends) with per-end cursors; PostsView's anchored mode scrolls to the post, observes top + bottom sentinels, and preserves scroll position on upward prepend so the page doesn't jump. Normal feed mode unchanged. Closes the remaining half of the post-navigation work. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/posts.py | 27 ++- backend/app/services/post_feed_service.py | 86 ++++++++-- frontend/src/stores/posts.js | 65 ++++++++ frontend/src/views/PostsView.vue | 190 +++++++++++++++++----- tests/test_api_posts.py | 68 +++++++- 5 files changed, 382 insertions(+), 54 deletions(-) diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index d96e523..9a3edb2 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -18,6 +18,8 @@ async def list_posts(): artist_id_raw = args.get("artist_id") platform = args.get("platform") or None limit_raw = args.get("limit", "24") + direction = args.get("direction", "older") + around_raw = args.get("around") try: limit = int(limit_raw) @@ -26,6 +28,16 @@ async def list_posts(): if limit < 1 or limit > 100: return _bad("invalid_limit", detail="limit must be between 1 and 100") + if direction not in ("older", "newer"): + return _bad("invalid_direction", detail="direction must be 'older' or 'newer'") + + around_id = None + if around_raw is not None: + try: + around_id = int(around_raw) + except ValueError: + return _bad("invalid_around", detail="around must be an integer post id") + artist_id = None if artist_id_raw is not None: try: @@ -40,11 +52,20 @@ async def list_posts(): ) async with get_session() as session: - try: - page = await PostFeedService(session).scroll( - cursor=cursor, artist_id=artist_id, + svc = PostFeedService(session) + if around_id is not None: + result = await svc.around( + post_id=around_id, artist_id=artist_id, platform=platform, limit=limit, ) + if result is None: + return _bad("not_found", status=404, detail=f"post id={around_id}") + return jsonify(result) + try: + page = await svc.scroll( + cursor=cursor, artist_id=artist_id, + platform=platform, limit=limit, direction=direction, + ) except ValueError as exc: # Service raises ValueError for malformed cursors only; # limit bounds are validated above. diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index 974427e..319d645 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -56,9 +56,17 @@ class PostFeedService: artist_id: int | None = None, platform: str | None = None, limit: int = 24, + direction: str = "older", ) -> dict: + """Paginate the feed from `cursor`. direction='older' walks back in + time (default, infinite-scroll down); direction='newer' walks forward + (scroll up in an anchored view). Items are always returned in feed + (descending) order; `next_cursor` points to the far edge in the + requested direction (null when exhausted).""" if limit < 1 or limit > 100: raise ValueError("limit must be between 1 and 100") + if direction not in ("older", "newer"): + raise ValueError("direction must be 'older' or 'newer'") sort_key = _sort_key() stmt = ( @@ -72,22 +80,37 @@ class PostFeedService: stmt = stmt.where(Source.platform == platform) if cursor: cur_ts, cur_id = decode_cursor(cursor) - stmt = stmt.where( - or_( + if direction == "older": + stmt = stmt.where(or_( sort_key < cur_ts, and_(sort_key == cur_ts, Post.id < cur_id), - ) - ) + )) + else: + stmt = stmt.where(or_( + sort_key > cur_ts, + and_(sort_key == cur_ts, Post.id > cur_id), + )) - stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1) + if direction == "older": + stmt = stmt.order_by(sort_key.desc(), Post.id.desc()) + else: + stmt = stmt.order_by(sort_key.asc(), Post.id.asc()) + stmt = stmt.limit(limit + 1) rows = (await self.session.execute(stmt)).all() + has_more = len(rows) > limit + rows = rows[:limit] + if direction == "newer": + # Fetched ascending (closest-newer first); flip to feed order. + rows = list(reversed(rows)) + next_cursor: str | None = None - if len(rows) > limit: - last_post, _, _ = rows[limit - 1] - last_key = last_post.post_date or last_post.downloaded_at - next_cursor = encode_cursor(last_key, last_post.id) - rows = rows[:limit] + if has_more and rows: + # Far edge in the travel direction: oldest row going older, + # newest row going newer (rows is descending for display). + edge_post = rows[-1][0] if direction == "older" else rows[0][0] + edge_key = edge_post.post_date or edge_post.downloaded_at + next_cursor = encode_cursor(edge_key, edge_post.id) post_ids = [p.id for p, _, _ in rows] thumbs_map = await self._thumbnails_for(post_ids) @@ -99,6 +122,49 @@ class PostFeedService: ] return {"items": items, "next_cursor": next_cursor} + async def around( + self, + *, + post_id: int, + artist_id: int | None = None, + platform: str | None = None, + limit: int = 12, + ) -> dict | None: + """A window centered on `post_id`: up to `limit` newer posts + the + post + up to `limit` older posts, in feed (descending) order, with a + cursor for each end. Returns None if the post doesn't exist.""" + anchor = (await self.session.execute( + select(Post, Artist, Source) + .join(Source, Post.source_id == Source.id) + .join(Artist, Source.artist_id == Artist.id) + .where(Post.id == post_id) + )).one_or_none() + if anchor is None: + return None + anchor_post, anchor_artist, anchor_source = anchor + anchor_key = anchor_post.post_date or anchor_post.downloaded_at + anchor_cursor = encode_cursor(anchor_key, anchor_post.id) + + older = await self.scroll( + cursor=anchor_cursor, artist_id=artist_id, platform=platform, + limit=limit, direction="older", + ) + newer = await self.scroll( + cursor=anchor_cursor, artist_id=artist_id, platform=platform, + limit=limit, direction="newer", + ) + thumbs_map = await self._thumbnails_for([anchor_post.id]) + atts_map = await self._attachments_for([anchor_post.id]) + anchor_item = self._to_dict( + anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map, + ) + return { + "items": newer["items"] + [anchor_item] + older["items"], + "cursor_older": older["next_cursor"], + "cursor_newer": newer["next_cursor"], + "anchor_id": anchor_post.id, + } + async def get_post(self, post_id: int) -> dict | None: row = (await self.session.execute( select(Post, Artist, Source) diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index 926bb63..f02e980 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => { const done = ref(false) const filters = ref({ artist_id: null, platform: null }) + // In-context (anchored) view: bidirectional cursors so the feed can load + // newer posts on scroll-up and older posts on scroll-down around a post. + const cursorOlder = ref(null) + const cursorNewer = ref(null) + const doneOlder = ref(false) + const doneNewer = ref(false) + const anchorId = ref(null) + function _qs() { const q = {} if (cursor.value) q.cursor = cursor.value @@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => { return await api.get(`/api/posts/${id}`) } + // Load a window centered on `postId`: newer posts above, the post, older + // posts below. Sets both directional cursors for subsequent scrolling. + async function loadAround(postId) { + loading.value = true + error.value = null + anchorId.value = null + try { + const body = await api.get('/api/posts', { params: { around: postId } }) + items.value = body.items + cursorOlder.value = body.cursor_older + cursorNewer.value = body.cursor_newer + doneOlder.value = body.cursor_older == null + doneNewer.value = body.cursor_newer == null + anchorId.value = body.anchor_id + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + + async function loadOlder() { + if (loading.value || doneOlder.value || cursorOlder.value == null) return + loading.value = true + try { + const body = await api.get('/api/posts', { + params: { cursor: cursorOlder.value, direction: 'older' }, + }) + items.value.push(...body.items) + cursorOlder.value = body.next_cursor + if (body.next_cursor == null) doneOlder.value = true + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + + async function loadNewer() { + if (loading.value || doneNewer.value || cursorNewer.value == null) return + loading.value = true + try { + const body = await api.get('/api/posts', { + params: { cursor: cursorNewer.value, direction: 'newer' }, + }) + items.value.unshift(...body.items) + cursorNewer.value = body.next_cursor + if (body.next_cursor == null) doneNewer.value = true + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + return { items, cursor, loading, done, error, filters, + cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId, loadInitial, loadMore, getPostFull, + loadAround, loadOlder, loadNewer, } }) diff --git a/frontend/src/views/PostsView.vue b/frontend/src/views/PostsView.vue index 4df230b..a08a09d 100644 --- a/frontend/src/views/PostsView.vue +++ b/frontend/src/views/PostsView.vue @@ -1,39 +1,84 @@ diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index 1aa1ef0..ab0cad6 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail endpoint. Service-level fixtures are exercised by test_post_feed_service — here we focus on the HTTP surface (validation, status codes, dict shape). """ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest @@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client): assert body["error"] == "not_found" +@pytest.fixture +async def post_timeline(db): + """Five posts on distinct dates: posts[0] oldest … posts[4] newest.""" + artist = Artist(name="tl-api", slug="tl-api") + db.add(artist) + await db.flush() + source = Source( + artist_id=artist.id, platform="patreon", + url="https://p/tl-api", enabled=True, + ) + db.add(source) + await db.flush() + base = datetime(2026, 1, 1, tzinfo=UTC) + posts = [] + for i in range(5): + p = Post( + source_id=source.id, external_post_id=f"TL{i}", + post_title=f"post {i}", post_date=base + timedelta(days=i), + ) + db.add(p) + posts.append(p) + await db.commit() + return artist, source, posts + + +@pytest.mark.asyncio +async def test_around_returns_window_with_anchor(client, post_timeline): + _, _, posts = post_timeline + anchor = posts[2] + resp = await client.get(f"/api/posts?around={anchor.id}&limit=1") + assert resp.status_code == 200 + body = await resp.get_json() + assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"} + assert body["anchor_id"] == anchor.id + # limit=1: one newer + anchor + one older, in feed (desc) order. + assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id] + assert body["cursor_older"] is not None # posts[0] still older + assert body["cursor_newer"] is not None # posts[4] still newer + + +@pytest.mark.asyncio +async def test_around_404_for_unknown(client): + resp = await client.get("/api/posts?around=999999") + assert resp.status_code == 404 + assert (await resp.get_json())["error"] == "not_found" + + +@pytest.mark.asyncio +async def test_direction_newer_walks_forward(client, post_timeline): + _, _, posts = post_timeline + around = await client.get(f"/api/posts?around={posts[1].id}&limit=1") + cursor_newer = (await around.get_json())["cursor_newer"] + assert cursor_newer is not None + resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5") + assert resp.status_code == 200 + # Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order. + assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id] + + +@pytest.mark.asyncio +async def test_rejects_bad_direction(client): + resp = await client.get("/api/posts?direction=sideways") + assert resp.status_code == 400 + assert (await resp.get_json())["error"] == "invalid_direction" + + @pytest.mark.asyncio async def test_detail_returns_uncapped_thumbnails(client, db): """Feed query caps thumbnails at 6 for previews; detail endpoint