diff --git a/.planning/phases/02-content-core/02-RESEARCH.md b/.planning/phases/02-content-core/02-RESEARCH.md new file mode 100644 index 0000000..3c8ecf6 --- /dev/null +++ b/.planning/phases/02-content-core/02-RESEARCH.md @@ -0,0 +1,1085 @@ +# Phase 2: Content Core — Research + +**Researched:** 2026-06-07 +**Domain:** Social content pipeline (photo posts, stories, milestones, social graph, Redis feed, block/report) +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **D-01:** Post creation entry point is the center **+** button in the mobile bottom nav and a corresponding button in the desktop sidebar. Tapping/clicking it opens a **bottom sheet** (Sheet component) with three type choices: Photo, Story, Milestone. +- **D-02:** After selecting a type from the sheet, the creation form opens as a **full-screen modal overlay** (Dialog). No URL navigation change — the owner stays on the current page while creating. +- **D-03:** After successfully posting, the modal closes and the owner **lands on the feed (/feed) with the new post at the top**. Feed re-fetches on mutation success. +- **D-04:** After Phase 2, the **home route (/)** redirects to **/feed**. The feed is the primary landing page after login. +- **D-05:** The **"Home" link** in the sidebar and mobile bottom nav points to **/feed**. Label stays "Home". +- **D-06:** Onboarding redirect logic stays: zero pets → /onboarding/pet; has pets but no follows → feed with empty state. +- **D-07:** Stories use **lazy-delete on read**: `expires_at` column, all queries filter `WHERE expires_at > NOW()`. Weekly pg_cron cleanup — no real-time cron in Phase 2. +- **D-08:** Story **view analytics retained 30 days** even after story expires. `story_views` table is NOT cascade-deleted when a story expires. +- **D-09:** Predefined milestone types only: `birthday`, `adoption_day`, `first_outing`, `vet_visit`, `litter`, plus auto-triggered follower milestones (500, 1k, 2.5k, 5k, 10k, 25k, 50k, 100k, 250k, 500k, 1M). +- **D-10:** Follower milestones are **auto-triggered** — owner receives an in-app prompt when threshold is crossed. The milestone post itself is still user-initiated. +- **D-11:** Custom milestone types deferred to a later phase. + +### Claude's Discretion + +- Feed pagination cursor implementation details (cursor vs offset, page size) +- Redis sorted set key naming convention for the fan-out feed +- Exact DB schema for `posts`, `stories`, `milestones` tables (beyond the decisions above) +- Story view deduplication logic (track unique viewers, not view count) +- Follow notification batching implementation + +### Deferred Ideas (OUT OF SCOPE) + +- Native advertising / sponsored posts +- Custom milestone types (free-text) +- Auto-follower milestone notifications infrastructure (fires in Phase 4) + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| PHOTO-01 | Pet can post a single photo with caption | Supabase Storage presigned upload reuse; Post + PostImage Prisma models; posts tRPC router | +| PHOTO-02 | Pet can post a photo carousel (up to 10 images) with caption | Same pipeline, PostImage array; shadcn Carousel (Embla); ordering via `position` field | +| PHOTO-03 | Owner can edit post caption after publishing | `posts.updateCaption` mutation with ownership assertion | +| PHOTO-04 | Owner can delete their pet's posts | `posts.delete` mutation; cascade deletes PostImages and removes from Redis feed sets | +| STORY-01 | Pet can post a story (photo or short video) that disappears after 24 hours | Separate `stories` table with `expires_at = NOW() + INTERVAL '24 hours'`; presigned upload reuse | +| STORY-02 | Story rings appear on pet's profile avatar while active stories exist | Query `stories` for `expires_at > NOW()` per pet; conditional ring class on Avatar | +| STORY-03 | Owner can see a list of pets that viewed their pet's story | `story_views` table; deduplication by `(storyId, viewerPetId)`; retained 30 days | +| MILE-01 | Pet can create a milestone post with a structured type | `milestones` table with `MilestoneType` enum; `milestones` tRPC router | +| MILE-02 | Milestone posts displayed with a distinct visual format | MilestoneCard component (`bg-orange-50`); Profile `Milestones` tab | +| SOCL-01 | Pet can follow another pet | `follows` explicit join table; `follows.create` mutation; fan-out to Redis feed | +| SOCL-02 | Pet can unfollow another pet | `follows.delete` mutation; no feed fan-out cleanup in Phase 2 (feed TTL handles it) | +| SOCL-03 | Owner can view their pet's followers and following lists | `follows.listFollowers` / `follows.listFollowing` queries | +| FEED-01 | Owner sees a chronological home feed from followed pets | Redis sorted set fan-out; `feed.getFeed` tRPC infinite query; `useInfiniteQuery` with cursor | +| SAFE-01 | User can block another pet/owner | `blocks` table; `blocks.create` mutation; filter from feed and profile queries | +| SAFE-02 | User can report a post or pet profile for review | `reports` table with reason enum and optional notes; `reports.create` mutation | + + +--- + +## Summary + +Phase 2 builds the complete content core of PawFeed: photo posts (single + carousel), 24-hour stories, structured milestone posts, pet-to-pet social graph (follow/unfollow), the Redis fan-out home feed, and the safety baseline (block/report) that must ship before any public content is accessible. All capabilities extend the Phase 1 foundation directly — the Supabase Storage presigned upload pipeline, tRPC router patterns, ownership assertion helper, and Prisma 7 schema are all reused without modification. + +The most complex piece is the Upstash Redis fan-out feed. When a pet posts, the server ZADDs the post ID into the Redis sorted set of every follower's feed inbox (score = Unix timestamp). Feed retrieval is a ZRANGE with `byScore:true, rev:true, withScores:true` — fast, pre-computed, no SQL fan-out. TanStack Query v5's `useInfiniteQuery` drives cursor-based infinite scroll on the client, with the last post's timestamp as the next cursor. Page size 20 (fetch 21 to detect hasNextPage) is the standard choice. + +Stories are a separate `stories` table (not posts), a locked architectural decision from Phase 1 research. The lazy-delete pattern (filter `expires_at > NOW()`) is the simplest valid approach for Phase 2 — no real-time expiry daemon needed. Story view tracking is a deduplicated `story_views` table retained 30 days via a separate cleanup job. The follow graph is pet-to-pet (an explicit `follows` join table on the `Pet` model), never owner-to-owner. Block filtering applies at the query layer — blocked pets are excluded from feed queries and profile visibility. + +**Primary recommendation:** Implement in dependency order: (1) Prisma schema migration for all new models, (2) Supabase Storage bucket for post media, (3) tRPC routers (posts, stories, milestones, follows, blocks, reports), (4) Redis feed router, (5) UI components (PostCard, StoryRing, MilestoneCard), (6) Feed page with infinite scroll. Block/report infrastructure must be complete before any feed content is publicly visible. + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Photo upload (presigned URL generation) | API / Backend (tRPC) | — | Server must sign URLs with service credentials; never client-side | +| Photo bytes delivery | CDN / Static (Supabase Storage CDN) | — | Binary never routes through API server | +| Post creation, caption edit, delete | API / Backend (tRPC) | — | Ownership assertion, DB write, Redis fan-out | +| Story creation & expiry filtering | API / Backend (tRPC) | Database (pg_cron) | Business logic + lazy delete on read; cleanup is DB-side | +| Story view recording | API / Backend (tRPC) | — | Deduplication logic requires server-side upsert | +| Milestone creation | API / Backend (tRPC) | — | Type validation (enum), ownership assertion | +| Feed retrieval (infinite scroll) | API / Backend (tRPC) | Redis (Upstash) | tRPC router hydrates from Redis sorted set; Redis is the pre-computed store | +| Feed fan-out on post/follow | API / Backend (tRPC) | Redis (Upstash) | Post mutation triggers ZADD to all follower inboxes | +| Follow / Unfollow | API / Backend (tRPC) | Redis (Upstash) | Prisma DB write + Redis fan-out of existing posts on follow | +| Block / Report | API / Backend (tRPC) | Database | Ownership check + DB write; no Redis involvement | +| Feed infinite scroll UI | Browser / Client | — | IntersectionObserver sentinel + useInfiniteQuery | +| Story ring display | Browser / Client | — | Conditional CSS class based on query result | +| Story viewer progress bar | Browser / Client | — | CSS transition, client-only timer | +| Optimistic follow/unfollow | Browser / Client | — | TanStack Query optimistic update pattern | +| Post carousel navigation | Browser / Client | — | Embla Carousel via shadcn; client-side scroll | + +--- + +## Standard Stack + +### Core (all already installed in Phase 1 — no new installs required) + +| Library | Installed Version | Purpose | Why Standard | +|---------|------------------|---------|--------------| +| `@upstash/redis` | 1.38.0 | Redis sorted set feed inbox, fan-out | HTTP-based serverless Redis; already installed | +| `@prisma/client` | 7.8.0 | New models: Post, Story, Follow, Block, Report | Already installed; WASM engine | +| `@supabase/supabase-js` | 2.107.0 | Supabase Storage presigned upload (reuse) | Already installed | +| `@trpc/server` / `@trpc/client` | 11.17.0 | New routers: posts, stories, milestones, follows, blocks, reports, feed | Already installed | +| `@tanstack/react-query` | 5.101.0 | `useInfiniteQuery` for feed infinite scroll | Already installed | +| `zod` | 4.4.3 | Input validation for all new mutations | Already installed | +| `lucide-react` | 1.17.0 | PostCard icons, milestone type icons | Already installed | + +### New shadcn Components (install via shadcn CLI — no npm package installs) + +| Component | shadcn Command | Used For | +|-----------|---------------|---------| +| `carousel` | `npx shadcn@latest add carousel` | Photo carousel in PostCard (wraps Embla) | +| `sheet` | `npx shadcn@latest add sheet` | Post type picker, story viewer, report panel | +| `alert-dialog` | `npx shadcn@latest add alert-dialog` | Block/delete/unfollow confirmations | +| `scroll-area` | `npx shadcn@latest add scroll-area` | Feed container, follower/following lists | +| `tabs` | `npx shadcn@latest add tabs` | Profile Posts / Milestones tab bar | +| `popover` | `npx shadcn@latest add popover` | Milestone type picker | +| `radio-group` | `npx shadcn@latest add radio-group` | Report reason selector | + +shadcn components are copied into `src/components/ui/` — they are not npm packages, so no package legitimacy audit is needed for them. The Embla Carousel library (`embla-carousel`, `embla-carousel-react`) is installed as a transitive dependency of the `carousel` shadcn component. + +### New npm Package (one addition) + +| Library | Version | Purpose | Why | +|---------|---------|---------|-----| +| `react-intersection-observer` | 10.0.3 | Feed scroll sentinel (IntersectionObserver hook) | Minimal wrapper around native API; avoids manual ref management; well-established | + +**Installation:** +```bash +npm install react-intersection-observer +npx shadcn@latest add carousel sheet alert-dialog scroll-area tabs popover radio-group +``` + +**Version verification:** +```bash +npm view react-intersection-observer version # → 10.0.3 (verified 2026-06-07) +npm view embla-carousel version # → 8.6.0 (verified 2026-06-07) +npm view embla-carousel-react version # → 8.6.0 (verified 2026-06-07) +npm view @upstash/redis version # → 1.38.0 (already installed, verified) +``` + +--- + +## Package Legitimacy Audit + +| Package | Registry | slopcheck | Disposition | +|---------|----------|-----------|-------------| +| `react-intersection-observer` | npm | [OK] | Approved — 10+ years old, 4M+ weekly downloads, official GitHub: `thebuilder/react-intersection-observer` | +| `embla-carousel` | npm | [OK] | Approved — installed transitively by shadcn carousel; slopcheck verified [OK] | +| `embla-carousel-react` | npm | [OK] | Approved — installed transitively by shadcn carousel; slopcheck verified [OK] | + +**Packages removed due to slopcheck [SLOP] verdict:** none + +**Packages flagged as suspicious [SUS]:** none + +No postinstall scripts found for any of the above packages (verified via `npm view scripts.postinstall`). + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +Owner (browser) + │ + ├─[+ button tap]──▶ Sheet (post type picker) ──▶ Dialog (creation form) + │ │ + │ presigned upload flow + │ (reuse Phase 1 pipeline) + │ │ + │ Supabase Storage + │ (posts/ path prefix) + │ │ + ├─[form submit]────▶ tRPC: posts.create ◀─────────────┘ + │ │ + │ ├─ Prisma: INSERT Post + PostImages + │ │ + │ └─ Redis fan-out: + │ ZADD feed:{followerPetId} score=timestamp member=postId + │ (for every follower of the posting pet) + │ + ├─[/feed page load]▶ tRPC: feed.getFeed (infinite query) + │ │ + │ ├─ Redis: ZRANGE feed:{activePetId} (score-based, rev) + │ │ returns [postId, score, postId, score, ...] + │ │ + │ └─ Prisma: findMany({ where: { id: { in: postIds } } }) + │ + block filter applied here + │ returns hydrated Post[] with pet info + │ + ├─[scroll to bottom]▶ IntersectionObserver sentinel fires + │ └─ fetchNextPage() with cursor = lastPostTimestamp + │ + ├─[follow pet]──────▶ tRPC: follows.create + │ │ + │ ├─ Prisma: INSERT Follow + │ │ + │ └─ Redis backfill: + │ query recent N posts from followee + │ ZADD feed:{activePetId} for each + │ + └─[block pet]───────▶ tRPC: blocks.create + └─ Prisma: INSERT Block + (no Redis change; filter applied at feed hydration) +``` + +### Recommended Project Structure (Phase 2 additions) + +``` +src/ +├── trpc/routers/ +│ ├── posts.ts # create, updateCaption, delete, byPetId +│ ├── stories.ts # create, list (with expiry filter), recordView, getViewers +│ ├── milestones.ts # create, list (by pet), byPetId +│ ├── follows.ts # create (follow), delete (unfollow), listFollowers, listFollowing +│ ├── blocks.ts # create (block), list (my blocks) +│ ├── reports.ts # create (report) +│ ├── feed.ts # getFeed (infinite, Redis-backed) +│ └── _app.ts # extended with all new routers +├── lib/ +│ ├── supabase-storage.ts # already exists — extend STORAGE_BUCKET or add posts bucket +│ ├── redis.ts # NEW: @upstash/redis singleton (reuse installed package) +│ └── feed-helpers.ts # NEW: Redis key builders, fan-out helpers +├── components/ +│ ├── feed/ +│ │ ├── PostCard.tsx # single post card (photo or milestone) +│ │ ├── PostCardCarousel.tsx # carousel variant (2–10 images) +│ │ ├── MilestoneCard.tsx # bg-orange-50 milestone display +│ │ ├── SkeletonCard.tsx # loading skeleton +│ │ ├── FeedList.tsx # infinite scroll list with sentinel +│ │ └── StoryTray.tsx # horizontal story ring row at feed top +│ ├── stories/ +│ │ ├── StoryRing.tsx # Avatar wrapper with conditional orange ring +│ │ └── StoryViewer.tsx # full-screen story viewer (Sheet) +│ ├── post-creation/ +│ │ ├── PostTypeSheet.tsx # bottom sheet with 3 type choices +│ │ ├── PhotoPostForm.tsx # multi-image upload + caption +│ │ ├── StoryForm.tsx # single image upload +│ │ └── MilestoneForm.tsx # type picker + optional caption/photo +│ └── social/ +│ ├── FollowButton.tsx # follow/unfollow with optimistic update +│ └── PetListItem.tsx # row in followers/following lists +├── app/(app)/ +│ ├── feed/ +│ │ └── page.tsx # home feed page (RSC shell + FeedList client) +│ ├── posts/ +│ │ └── new/page.tsx # redirects — creation is modal, not a route +│ ├── stories/ +│ │ └── [storyId]/page.tsx # story viewer route (or modal) +│ └── pets/[petId]/ +│ ├── followers/page.tsx # followers list +│ └── following/page.tsx # following list +``` + +### Pattern 1: Prisma 7 Schema — New Models + +**What:** Explicit many-to-many follow with metadata, separate stories table, posts with image array, blocks, reports. + +**Key Prisma 7 constraint:** All connection URLs live in `prisma.config.ts` (not schema.prisma). Self-referential relations require disambiguation with relation names. + +```prisma +// Source: Prisma official docs — self-relations + explicit many-to-many + +enum PostType { + PHOTO + MILESTONE +} + +enum MilestoneType { + BIRTHDAY + ADOPTION_DAY + FIRST_OUTING + VET_VISIT + LITTER + FOLLOWERS_500 + FOLLOWERS_1K + FOLLOWERS_2_5K + FOLLOWERS_5K + FOLLOWERS_10K + FOLLOWERS_25K + FOLLOWERS_50K + FOLLOWERS_100K + FOLLOWERS_250K + FOLLOWERS_500K + FOLLOWERS_1M +} + +enum ReportReason { + SPAM + HARASSMENT + INAPPROPRIATE + MISINFORMATION + OTHER +} + +model Post { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String + type PostType @default(PHOTO) + caption String? @db.VarChar(500) + images PostImage[] + milestone Milestone? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([petId, createdAt(sort: Desc)]) +} + +model PostImage { + id String @id @default(cuid()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String + storageKey String // Supabase Storage object key + position Int // 0-based order in carousel + width Int? + height Int? + + @@unique([postId, position]) + @@index([postId]) +} + +model Milestone { + id String @id @default(cuid()) + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + postId String @unique + milestoneType MilestoneType + notes String? @db.VarChar(500) // free text for 'litter' type +} + +model Story { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String + storageKey String // Supabase Storage object key + expiresAt DateTime // set to NOW() + INTERVAL '24 hours' on create + views StoryView[] + createdAt DateTime @default(now()) + + @@index([petId, expiresAt]) // supports lazy-delete filter query +} + +model StoryView { + story Story @relation(fields: [storyId], references: [id]) // NO onDelete:Cascade + storyId String + viewerPet Pet @relation("StoryViewer", fields: [viewerPetId], references: [id]) + viewerPetId String + viewedAt DateTime @default(now()) + + @@id([storyId, viewerPetId]) // deduplication: one view per viewer per story + @@index([viewedAt]) // supports 30-day cleanup job +} + +model Follow { + follower Pet @relation("Follower", fields: [followerPetId], references: [id], onDelete: Cascade) + followerPetId String + followee Pet @relation("Followee", fields: [followeePetId], references: [id], onDelete: Cascade) + followeePetId String + createdAt DateTime @default(now()) + + @@id([followerPetId, followeePetId]) + @@index([followerPetId]) + @@index([followeePetId]) +} + +model Block { + blocker Pet @relation("Blocker", fields: [blockerPetId], references: [id], onDelete: Cascade) + blockerPetId String + blocked Pet @relation("Blocked", fields: [blockedPetId], references: [id], onDelete: Cascade) + blockedPetId String + createdAt DateTime @default(now()) + + @@id([blockerPetId, blockedPetId]) + @@index([blockerPetId]) +} + +model Report { + id String @id @default(cuid()) + reporter Pet @relation("Reporter", fields: [reporterPetId], references: [id]) + reporterPetId String + targetPostId String? // null if reporting a profile + targetPetId String? // null if reporting a post + reason ReportReason + notes String? @db.VarChar(500) + createdAt DateTime @default(now()) + + @@index([reporterPetId]) +} +``` + +**Pet model additions (back-relations to add):** +```prisma +model Pet { + // ... existing fields ... + posts Post[] + stories Story[] + storyViews StoryView[] @relation("StoryViewer") + following Follow[] @relation("Follower") + followers Follow[] @relation("Followee") + blocking Block[] @relation("Blocker") + blockedBy Block[] @relation("Blocked") + reports Report[] @relation("Reporter") +} +``` + +### Pattern 2: Redis Feed Key Convention and Fan-Out + +**What:** Each pet has a Redis sorted set that is its personal feed inbox. Score = Unix timestamp (milliseconds). Member = postId. + +**Key naming convention (Claude's Discretion — recommended):** +``` +feed:{petId} → sorted set, score=timestamp, member=postId +``` + +**Fan-out on post create:** +```typescript +// Source: fan-out on write pattern (standard for social feeds at v1 scale) +// In posts.create tRPC mutation, after Prisma INSERT: + +async function fanOutPost( + redis: Redis, + postId: string, + petId: string, + createdAt: Date, + prisma: PrismaClient +) { + const score = createdAt.getTime(); // Unix ms as score + + // Get all followers of this pet + const followers = await prisma.follow.findMany({ + where: { followeePetId: petId }, + select: { followerPetId: true }, + }); + + if (followers.length === 0) return; + + // ZADD to each follower's feed inbox + const pipeline = redis.pipeline(); + for (const { followerPetId } of followers) { + pipeline.zadd(`feed:${followerPetId}`, { + score, + member: postId, + }); + } + // Also add to own feed so own posts are visible to the poster + pipeline.zadd(`feed:${petId}`, { score, member: postId }); + + await pipeline.exec(); +} +``` + +**Fan-out on follow (backfill recent posts):** +```typescript +// When pet A follows pet B, backfill B's recent posts into A's feed +async function backfillOnFollow( + redis: Redis, + followerPetId: string, + followeePetId: string, + prisma: PrismaClient, + limit = 20 +) { + const recentPosts = await prisma.post.findMany({ + where: { petId: followeePetId }, + orderBy: { createdAt: 'desc' }, + take: limit, + select: { id: true, createdAt: true }, + }); + + if (recentPosts.length === 0) return; + + const pipeline = redis.pipeline(); + for (const post of recentPosts) { + pipeline.zadd(`feed:${followerPetId}`, { + score: post.createdAt.getTime(), + member: post.id, + }); + } + await pipeline.exec(); +} +``` + +**Feed retrieval with cursor pagination:** +```typescript +// Source: @upstash/redis ZRANGE API (verified via Upstash docs 2026-06-07) +// In feed.getFeed tRPC infinite query: + +const PAGE_SIZE = 20; + +async function getFeedPage( + redis: Redis, + petId: string, + cursor: number | null // Unix ms timestamp; null = start from now +): Promise<{ postIds: string[]; nextCursor: number | null }> { + const maxScore = cursor ?? Date.now(); + const minScore = 0; + + // Fetch PAGE_SIZE + 1 to detect if there's a next page + const results = await redis.zrange( + `feed:${petId}`, + maxScore, + minScore, + { + byScore: true, + rev: true, + withScores: true, + count: PAGE_SIZE + 1, + } + ) as Array<{ member: string; score: number }>; + + const hasNextPage = results.length > PAGE_SIZE; + const items = hasNextPage ? results.slice(0, PAGE_SIZE) : results; + const nextCursor = hasNextPage ? items[items.length - 1].score - 1 : null; + + return { + postIds: items.map((r) => r.member), + nextCursor, + }; +} +``` + +**Feed hydration (batch Prisma query with block filter):** +```typescript +// After getting postIds from Redis, hydrate with Prisma: +const blockedIds = await prisma.block.findMany({ + where: { blockerPetId: activePetId }, + select: { blockedPetId: true }, +}); +const blockedPetIds = blockedIds.map((b) => b.blockedPetId); + +const posts = await prisma.post.findMany({ + where: { + id: { in: postIds }, + pet: { id: { notIn: blockedPetIds } }, + }, + include: { + pet: { select: { id: true, name: true, avatarKey: true } }, + images: { orderBy: { position: 'asc' } }, + milestone: true, + }, + orderBy: { createdAt: 'desc' }, +}); +``` + +### Pattern 3: tRPC Infinite Query (FEED-01) + +**What:** tRPC v11 procedure with `cursor` input exposes `useInfiniteQuery`. + +**Backend (tRPC router):** +```typescript +// Source: trpc.io/docs/client/react/useInfiniteQuery (verified 2026-06-07) +getFeed: protectedProcedure + .input( + z.object({ + petId: z.string(), + cursor: z.number().nullish(), // Unix ms timestamp + limit: z.number().min(1).max(50).default(20), + }) + ) + .query(async ({ ctx, input }) => { + // ownership assertion: petId must belong to ctx.userId + // ... get feed page from Redis ... + // ... hydrate with Prisma ... + return { posts, nextCursor }; + }), +``` + +**Client (React component):** +```typescript +// Source: TanStack Query v5 useInfiniteQuery docs (verified 2026-06-07) +const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + trpc.feed.getFeed.useInfiniteQuery( + { petId: activePetId }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialCursor: null, + staleTime: 30_000, + } + ); + +const allPosts = data?.pages.flatMap((page) => page.posts) ?? []; +``` + +**Infinite scroll sentinel:** +```typescript +// Source: react-intersection-observer docs + TanStack Query v5 pattern +import { useInView } from 'react-intersection-observer'; + +const { ref: sentinelRef, inView } = useInView({ threshold: 0 }); + +useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } +}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + +// In JSX: