+
+
+ {images.map((img) => (
+
+
+
+ ))}
+
+
+
+
+ {/* Index indicator */}
+
+ {current} / {images.length}
+
+
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| SQL JOIN feed query (`SELECT ... FROM posts JOIN follows ...`) | Redis sorted set fan-out (pre-computed per-user inbox) | Industry standard since ~2012 (Instagram, Twitter) | Feed load is O(1) Redis lookup vs O(follower count) SQL join |
+| Long-polling for new posts | Supabase Realtime WebSocket subscription | Supabase Realtime GA 2023 | Push-based feed updates without polling (Phase 3+ enhancement; Phase 2 uses TanStack Query refetch on mutation) |
+| OFFSET pagination | Cursor-based pagination (last item's timestamp/ID as cursor) | Industry-wide shift 2018-2020 | No duplicate/missing items when new content is inserted during scroll |
+| Stories stored as posts with TTL column | Separate `stories` table with `expires_at` + lazy-delete | Social platform architecture best practice | Prevents post model pollution, enables separate view tracking, simpler query patterns |
+| Uploading binary through API server | Presigned S3/Supabase Storage URL (client direct upload) | Standard practice since 2015 | API server not a bottleneck; no binary in memory; secure by design |
+| `useEffect` + manual IntersectionObserver | `react-intersection-observer` `useInView` hook | 2021-2022 | Correct observer lifecycle management; SSR-safe |
+
+**Deprecated/outdated patterns in this stack:**
+- `Prisma.findUnique` with client-side ownership check after fetch: replaced by `assertPetOwnership` helper (established in Phase 1 `pets.ts` — copy this pattern).
+- `OFFSET` in any feed, list, or social graph query: replaced by cursor everywhere.
+- Storing media keys as full URLs in the database: Phase 1 established storing `storageKey` only; use `getAvatarUrl` / `getMediaUrl` helpers for URL construction.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Post images share the `pet-avatars` Supabase Storage bucket with `posts/` path prefix (rather than a separate `post-media` bucket) | Standard Stack / Pattern 4 | Low — separate bucket is trivially configured; same API surface either way |
+| A2 | `@upstash/redis` 1.38.0 ZRANGE with `{withScores: true}` returns `Array<{member, score}>` objects (not interleaved flat array) | Pattern 2 / Pitfall 1 | Medium — if interleaved, feed hydration will fail; verify in test before using |
+| A3 | Follower milestone threshold values are hard-coded as a constant array in `src/lib/milestone-thresholds.ts` (not a `milestone_thresholds` DB table) | Pattern 1 (schema) | Low — a constant array is simpler and the thresholds don't change in Phase 2; D-09 provides no preference |
+| A4 | `react-intersection-observer` is the appropriate sentinel hook (vs. native `useEffect` + `IntersectionObserver`) | Standard Stack | Low — the native API is a valid fallback; this package is widely used and [OK] verified |
+| A5 | "Seen by viewer" ring state (gray ring for all-seen stories) is computed on the client using the story viewer list — no separate `hasSeenAll` DB column | Pattern 5 | Low — Phase 2 can use `ring-orange-500` unconditionally for all active stories (UI-SPEC shows gray ring as a distinction, but it's a detail that doesn't block STORY-02) |
+
+---
+
+## Open Questions
+
+1. **Redis feed set maximum size / TTL**
+ - What we know: No explicit TTL or max size is configured on the feed sorted set.
+ - What's unclear: Should old feed entries be trimmed (ZREMRANGEBYSCORE) to prevent unbounded set growth?
+ - Recommendation: For Phase 2 at v1 scale, no trim needed. Add `ZREMRANGEBYSCORE feed:{petId} 0 {90_days_ago_ms}` in a weekly Supabase pg_cron job alongside story cleanup.
+
+2. **Story upload: photo only or photo + video in Phase 2?**
+ - What we know: STORY-01 says "photo or short video." VIDEO-01/02 is deferred to Phase 5. The UI-SPEC shows single image upload for stories.
+ - What's unclear: Should video stories be accepted in Phase 2?
+ - Recommendation: Phase 2 stories = photo only (images only via presigned upload). Story video is Phase 5 scope (same Mux transcoding pipeline as video posts). The `stories.storageKey` field accepts image keys only in Phase 2.
+
+3. **Fan-out scope: own posts in own feed?**
+ - What we know: Pattern 2 above recommends also adding the pet's own posts to `feed:{petId}` so they appear in their own feed.
+ - What's unclear: Should the feed show the poster's own posts, or only posts from followed pets?
+ - Recommendation: Include own posts in feed (industry standard: you see your own posts in your timeline). ZADD `feed:{petId}` alongside follower fan-out.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Supabase Storage (Supabase project) | Photo/story upload | ✓ | 2.107.0 (@supabase/supabase-js) | — |
+| Upstash Redis (REST endpoint) | Feed fan-out, retrieval | ✓ | 1.38.0 (@upstash/redis) | — (env vars must be set) |
+| `UPSTASH_REDIS_REST_URL` env var | redis.ts singleton | Requires `.env.local` | — | Blocks feed if missing |
+| `UPSTASH_REDIS_REST_TOKEN` env var | redis.ts singleton | Requires `.env.local` | — | Blocks feed if missing |
+| `npx prisma migrate dev` | Schema migration | ✓ (prisma 7.8.0 installed) | 7.8.0 | — |
+| shadcn CLI (`npx shadcn@latest add`) | New UI components | ✓ (shadcn 4.10.0 in package.json) | 4.10.0 | — |
+
+**Missing dependencies with no fallback:**
+- `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` must be added to `.env.local`. The feed router will throw if these are absent. Wave 0 task must check for these.
+
+**Missing dependencies with fallback:**
+- None — all runtime dependencies are covered by Phase 1's setup.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 4.1.8 |
+| Config file | `vitest.config.ts` (at project root) |
+| Quick run command | `npx vitest run --reporter=verbose` |
+| Full suite command | `npx vitest run --coverage` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| PHOTO-01 | `posts.create` mutation inserts Post + PostImage with correct petId | unit | `npx vitest run src/__tests__/posts.test.ts -t "create single photo"` | Wave 0 |
+| PHOTO-02 | `posts.create` rejects >10 images | unit | `npx vitest run src/__tests__/posts.test.ts -t "carousel limit"` | Wave 0 |
+| PHOTO-03 | `posts.updateCaption` requires ownership | unit | `npx vitest run src/__tests__/posts.test.ts -t "updateCaption forbidden"` | Wave 0 |
+| PHOTO-04 | `posts.delete` cascades PostImages | unit | `npx vitest run src/__tests__/posts.test.ts -t "delete cascades"` | Wave 0 |
+| STORY-01 | `stories.create` sets `expiresAt = now + 24h` | unit | `npx vitest run src/__tests__/stories.test.ts -t "expiresAt 24h"` | Wave 0 |
+| STORY-02 | `stories.hasActiveStory` filters by `expiresAt > now` | unit | `npx vitest run src/__tests__/stories.test.ts -t "hasActiveStory"` | Wave 0 |
+| STORY-03 | `stories.recordView` deduplicates (upsert) | unit | `npx vitest run src/__tests__/stories.test.ts -t "recordView dedup"` | Wave 0 |
+| MILE-01 | `milestones.create` rejects invalid milestone type | unit | `npx vitest run src/__tests__/milestones.test.ts -t "invalid type"` | Wave 0 |
+| SOCL-01 | `follows.create` inserts Follow row | unit | `npx vitest run src/__tests__/follows.test.ts -t "follow creates row"` | Wave 0 |
+| SOCL-02 | `follows.delete` removes Follow row | unit | `npx vitest run src/__tests__/follows.test.ts -t "unfollow removes row"` | Wave 0 |
+| SOCL-03 | `follows.listFollowers` scoped by petId | unit | `npx vitest run src/__tests__/follows.test.ts -t "listFollowers scoped"` | Wave 0 |
+| FEED-01 | `feed.getFeed` returns empty for pet with no follows | unit | `npx vitest run src/__tests__/feed.test.ts -t "empty feed"` | Wave 0 |
+| FEED-01 | `feed.getFeed` cursor pagination returns nextCursor | unit | `npx vitest run src/__tests__/feed.test.ts -t "cursor pagination"` | Wave 0 |
+| SAFE-01 | `blocks.create` inserts Block; blocked pet excluded from feed | unit | `npx vitest run src/__tests__/blocks.test.ts -t "block filters feed"` | Wave 0 |
+| SAFE-02 | `reports.create` inserts Report with reason | unit | `npx vitest run src/__tests__/reports.test.ts -t "report creates row"` | Wave 0 |
+
+### Sampling Rate
+
+- **Per task commit:** `npx vitest run src/__tests__/