feat(feed): enriched rendering on the public profile page - #1052
Conversation
/u/:username rendered posts with FeedPostCard but the backing /public/:username/posts endpoint skipped includeStructured, so visitors got flattened text + PNGs instead of the native stat grid, hover chart, and synced route map the owner and subscribing peers see. Privacy-neutral: only public/unlisted posts are listed, and structured carries exactly the author's opt-ins — the same payload anyone could fetch per post from /public/:username/feed/:postId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
fiddur
left a comment
There was a problem hiding this comment.
🛑 Changes required
The behaviour change itself is right and the privacy argument holds: listPublicFeedPostsPage only returns public/unlisted rows, and resolveActivityPresentation's structured branch is byte-identical to resolveStructuredContent's activity branch (same resolveActivityScalars, same resolveStructuredSeries, same include_map-gated resolveStructuredRoute), so this exposes nothing a visitor couldn't already fetch from /public/:username/feed/:postId. FeedPostCard and TimelineStructured already render structured with no authenticated fetches, so the profile page picks it up with no frontend change.
The one thing I'd like addressed before merge is cost, not correctness — see the inline comment. This unauthenticated, no-store, uncached endpoint now resolves the full structured payload for up to PROFILE_FEED_LIMIT = 50 posts per request, and the doc comment this PR edits previously singled out "the public profile listing" as the place that deliberately skipped that weight. The sibling per-post endpoint got a 50-entry LRU for exactly this reason; this one has no equivalent bound.
Non-blocking — fold into a later PR, don't re-roll this one
apps/web/src/pages/Feed/FeedPostCard.tsx(the block comment above the render branches) still says "Without it (public profile), the stat-gridActivityPostBody". After this change the public profile does carrystructured, so that branch is now only the legacy/pre-#1008 fallback — worth rewording next time that file is touched.- The new router comment is a bit heavy: the "same native stat grid / hover chart / synced map" clause restates what
includeStructured: trueplus the existing card code already say. ThePrivacy-neutral: …sentence is the genuinely non-obvious part and is worth keeping. - The new test seeds
series_metrics: []/include_map: false, so it only pins the empty-series path. The interesting assertions — a populatedseriescoming through, androuteappearing only underinclude_map— are untested on this endpoint (they are covered for/feed/:postId). Nice-to-have, not needed here.
- PROFILE_FEED_LIMIT 50 → 20 (authed /feed page size; also bounds the client's per-card chart/map instances) - structured resolution goes through the SAME LRU + key shape as the per-post endpoint (visibility filter still per-request, so un-sharing takes effect immediately despite the cache) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
fiddur
left a comment
There was a problem hiding this comment.
🛑 Changes required
The backend change itself is sound. I checked the parts that could have gone wrong and they hold up:
- Shared cache key is safe.
listPublicFeedPostsPageandgetFeedPostByIdboth selectFEED_POST_COLUMNSthrough the samemapFeedPost, so the two endpoints feedresolveStructuredContentstructurally identical records — sharing one key shape can't serve a payload resolved from a differently-shaped record./postsonly ever reads keys for rows thevisibility IN ('public','unlisted')filter returned, so afollowers-only payload cached via an authorized?token=request is unreachable from the listing, andupdateFeedPostbumpsupdated_aton every visibility/included_metrics/series_metrics/include_mapchange, so an un-share busts the key immediately. kind === 'activity'gate matches theincludeStructuredpath inresolveActivityPresentation(services/feed.ts:133), so the profile listing and the owner's/feedagree on which posts carrystructured.PROFILE_FEED_LIMIT = 20really does matchfeedPostsQuerySchema's.default(20), so that comment is accurate.- The new test asserts the real payload, not a shape it constructed, and each
startApp()builds a fresh router (fresh LRU), so there's no cross-test cache bleed. TimelineStructuredneeds no credentials — it renders purely from the inline payload — so the unauthenticated profile page can actually use what's now attached.
One thing must change before merge: docs/features/feed.md:524 now states the opposite of what the code does (see the inline comment).
Non-blocking — fold into a later PR, don't re-roll this one
Please don't push fixes for these here; a push starts another full review round for no real benefit. Batch them with other work.
- Duplicated resolution per post.
serializeFeedPostresolves the merged activity window andresolveActivityScalars, thenresolveStructuredContentresolves both again for the same record — roughly 2× the queries per post on a cache-cold anonymous request. Threading the already-resolved activity/scalars into the structured resolve (or making theincludeStructuredpath itself cacheable) would halve that. - No pagination behind the new cap. Dropping 50 → 20 with a hardcoded
offset: 0and no "Load more" on/u/:usernamemakes posts 21+ unreachable from the profile page. Cursor-paginating/public/:username/poststhe way/feedis (#1012) would restore the reach without restoring the weight. - LRU cap vs. the new writer. The cache is still
createRenderCache(50)while/postsnow writes up to 20 entries per profile request, so three concurrently-browsed profiles thrash it. Worth raising the cap now that a listing shares it. - Comment volume. The
/postsheader comment is now ~16 lines and the cache comment ~16, and a fair amount restates what the code already shows — "Privacy-neutral: every post here is public/unlisted" is visible from thelistPublicFeedPostsPagecall, and "Bounded to the latest page" duplicates thePROFILE_FEED_LIMITdocblock two screens up. The genuinely non-obvious part (why the key shape is shared, why the token stays out of it) is worth keeping; the rest could go. Same for the two comment lines above the newtoMatchObjectin the test. - Stale payload after an upstream delete. Deleting the underlying activity or its GPS track doesn't touch
feed_posts.updated_at, so a cachedstructuredcan linger up to the hour bucket. Identical to the existing/feed/:postIdtradeoff, just noting it now applies to the listing too.
…ap (review round 2) - feed.md + FeedPostCard comment said the public profile omits structured; both now describe the shipped behavior (list_feed is the one omitting surface) - structuredCache 50 → 200: the listing writes up to 20 entries per profile, so a handful of concurrent profiles would thrash a 50-cap - trimmed the two over-long router comments per review Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT
fiddur
left a comment
There was a problem hiding this comment.
✅ Approved
Both blocking items from the previous rounds are addressed and I couldn't find a new one.
What I re-verified in this round's diff:
- Docs now match the code.
docs/features/feed.md:521-530states the shipped behaviour (profile listing attaches the full structured payload through the shared per-post LRU, one fixed page of 20;list_feedis the one omitting surface), and the staleFeedPostCardcomment is corrected. That was the round-2 blocker. - Cap raise is safe for correctness.
createRenderCache(200)changes only retention, not key semantics: the key staysstructured:<user>:<postId>:<updated_at>:<hourBucket>, identical in both writers, so/postsand/feed/:postIdstill share entries legitimately (both selectFEED_POST_COLUMNSthroughmapFeedPost, so the records fed toresolveStructuredContentare structurally the same).updated_atisNOT NULL/Date, so the.getTime()in the listing key can't throw. - Privacy boundary still holds. The listing only reads keys for rows
listPublicFeedPostsPagereturned (visibility IN ('public','unlisted')), so afollowers-only payload cached via an authorized?token=request is unreachable from the profile, andupdateFeedPostbumpsupdated_aton everyvisibility/included_metrics/series_metrics/include_mapchange, so an un-share or a narrowed opt-in busts the key immediately. PROFILE_FEED_LIMIT = 20matchesfeedPostsQuerySchema's default, so the comment is accurate and the per-request weight (20 structured resolves, 20 charts/maps client-side) is now the same as the authed/feedpage it mirrors.
Non-blocking — fold into a later PR, don't re-roll this one
Please don't push fixes for these here; a push starts another full review round for no benefit. Batch them with the follow-up work.
- The dropped rationale for the old cap still applies. The 50-cap comment used to say "a smaller cap than the image LRU because a payload can be large (per-block sample cap is #972)". That's still true — an article payload can hold up to 100 resolved blocks and an activity payload has no sample cap (5s buckets over the whole merged window) — so 200 entries quadruples worst-case heap retention while entry size is still unbounded. Once #972's sample cap lands, an entry-count bound is fine; before then a rough weight-based bound (or keeping the structured cap below the image cap) would be the safer shape.
- Still carried over from round 2: the duplicated per-post resolution (
serializeFeedPostresolves activity + scalars, thenresolveStructuredContentresolves both again on a cache miss) and the missing pagination behind the new cap (hardcodedoffset: 0, no "Load more" on/u/:username, so posts 21+ are unreachable from the profile page). Both fit naturally in one follow-up. - Doc precision: "attaches the same full structured payload per post" is true for
activityposts only — anarticlepost on the profile still renders frompost.articleviaArticleContent, and therecord.kind === 'activity'gate (correctly) mirrorsresolveActivityPresentation. A four-word qualifier would make that exact. - Comment volume: trimmed, but the cache block is still 12 lines and the route header 10. The genuinely non-obvious parts (why the key shape is shared, why the token stays out of it, why the hourly term exists) earn their space; the rest — "serialized like the authenticated
/feed", "Structured resolution is expensive" — restates the adjacent code. - Test coverage: the new test still seeds
series_metrics: []/include_map: false, so it pins only the empty-series path on this endpoint. A populatedseriesand a route appearing only underinclude_mapare covered for/feed/:postIdbut not here.
Fredrik: "On the public page https://aurboda.net/u/fiddur I think we should do the enriched rendering of metrics and maps as well, since we are rendering it."
The page already renders posts with
FeedPostCard, which shows the native stat grid + interactive hover chart + synced route map wheneverpost.structuredis present — butGET /public/:username/postsdeliberately skippedincludeStructured("skip the weight"), so visitors got flattened text and static PNGs. This flips that opt-in on.serializeFeedPost(..., { includeStructured: true, settings })on the public posts listing; no web changes needed — the card and its PNG-suppression logic (feat(feed): own feed renders the peer-identical native card (#1008) #1009/feat(feed): combined multi-metric chart + time-synced map on feed cards (#1011) #1013) do the rest.structuredcarries exactly the author's per-post opt-ins (series viaseries_metrics, route viainclude_map) — the identical payload anyone could already fetch per post from/public/:username/feed/:postId.no-store), so this adds per-post series/route resolution server-side. The owner's authed/feeddoes the same at 20/page. If profile traffic ever makes this hot, the per-post LRU used by the structured endpoint (or pagination, Keyset pagination polish: cursor µs precision, invalid-Date guard, feed index, test name #1023's sibling) is the follow-up — not needed for a single-user instance today.structuredmatches the per-post endpoint shape.🤖 Generated with Claude Code
https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT