Skip to content

feat(feed): enriched rendering on the public profile page - #1052

Merged
fiddur merged 3 commits into
developfrom
public-profile-structured
Aug 21, 2026
Merged

feat(feed): enriched rendering on the public profile page#1052
fiddur merged 3 commits into
developfrom
public-profile-structured

Conversation

@fiddur

@fiddur fiddur commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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 whenever post.structured is present — but GET /public/:username/posts deliberately skipped includeStructured ("skip the weight"), so visitors got flattened text and static PNGs. This flips that opt-in on.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CwoP1SqJhHgHiEEoEQtUjT

/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
fiddur marked this pull request as ready for review August 21, 2026 16:35

@fiddur fiddur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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-grid ActivityPostBody". After this change the public profile does carry structured, 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: true plus the existing card code already say. The Privacy-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 populated series coming through, and route appearing only under include_map — are untested on this endpoint (they are covered for /feed/:postId). Nice-to-have, not needed here.

Comment thread apps/backend/src/routes/feed-public-router.ts Outdated
- 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 fiddur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛑 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. listPublicFeedPostsPage and getFeedPostById both select FEED_POST_COLUMNS through the same mapFeedPost, so the two endpoints feed resolveStructuredContent structurally identical records — sharing one key shape can't serve a payload resolved from a differently-shaped record. /posts only ever reads keys for rows the visibility IN ('public','unlisted') filter returned, so a followers-only payload cached via an authorized ?token= request is unreachable from the listing, and updateFeedPost bumps updated_at on every visibility/included_metrics/series_metrics/include_map change, so an un-share busts the key immediately.
  • kind === 'activity' gate matches the includeStructured path in resolveActivityPresentation (services/feed.ts:133), so the profile listing and the owner's /feed agree on which posts carry structured.
  • PROFILE_FEED_LIMIT = 20 really does match feedPostsQuerySchema'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.
  • TimelineStructured needs 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. serializeFeedPost resolves the merged activity window and resolveActivityScalars, then resolveStructuredContent resolves 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 the includeStructured path itself cacheable) would halve that.
  • No pagination behind the new cap. Dropping 50 → 20 with a hardcoded offset: 0 and no "Load more" on /u/:username makes posts 21+ unreachable from the profile page. Cursor-paginating /public/:username/posts the way /feed is (#1012) would restore the reach without restoring the weight.
  • LRU cap vs. the new writer. The cache is still createRenderCache(50) while /posts now 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 /posts header 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 the listPublicFeedPostsPage call, and "Bounded to the latest page" duplicates the PROFILE_FEED_LIMIT docblock 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 new toMatchObject in 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 cached structured can linger up to the hour bucket. Identical to the existing /feed/:postId tradeoff, just noting it now applies to the listing too.

Comment thread apps/backend/src/routes/feed-public-router.ts Outdated
…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 fiddur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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-530 states the shipped behaviour (profile listing attaches the full structured payload through the shared per-post LRU, one fixed page of 20; list_feed is the one omitting surface), and the stale FeedPostCard comment is corrected. That was the round-2 blocker.
  • Cap raise is safe for correctness. createRenderCache(200) changes only retention, not key semantics: the key stays structured:<user>:<postId>:<updated_at>:<hourBucket>, identical in both writers, so /posts and /feed/:postId still share entries legitimately (both select FEED_POST_COLUMNS through mapFeedPost, so the records fed to resolveStructuredContent are structurally the same). updated_at is NOT NULL/Date, so the .getTime() in the listing key can't throw.
  • Privacy boundary still holds. The listing only reads keys for rows listPublicFeedPostsPage returned (visibility IN ('public','unlisted')), so a followers-only payload cached via an authorized ?token= request is unreachable from the profile, and updateFeedPost bumps updated_at on every visibility/included_metrics/series_metrics/include_map change, so an un-share or a narrowed opt-in busts the key immediately.
  • PROFILE_FEED_LIMIT = 20 matches feedPostsQuerySchema'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 /feed page 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 (serializeFeedPost resolves activity + scalars, then resolveStructuredContent resolves both again on a cache miss) and the missing pagination behind the new cap (hardcoded offset: 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 activity posts only — an article post on the profile still renders from post.article via ArticleContent, and the record.kind === 'activity' gate (correctly) mirrors resolveActivityPresentation. 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 populated series and a route appearing only under include_map are covered for /feed/:postId but not here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant