From ff607a604d8f0bbf9c36e184d5598eeaa74f3b82 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 23 Jul 2026 18:21:03 +0200 Subject: [PATCH] feat(feed): cold-start discovery feed for new pets with no follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new pet with zero posts of its own and no followees with posts saw a completely empty feed on first visit. Instead of that dead-feeling first impression, getFeed now falls back to recent posts from other pets of the same species, ranked by the existing algoScore (engagement + recency) — same idea as a "For You" feed, scoped by species instead of follows. Only kicks in on the first page (no cursor) of the primary Redis-backed path, so paginating an already-empty follow feed doesn't keep serving discovery pages. Response carries a `source: "following" | "discovery"` flag; FeedList shows a small dismissless banner explaining the posts aren't from follows yet, otherwise the feed renders identically (WelcomeCard, feed-mode toggle, PostCard list all unchanged). Added 2 regression tests covering the discovery-fallback trigger and the "don't discovery-fallback while paginating" edge case. --- messages/de.json | 3 +- messages/en.json | 3 +- src/__tests__/feed.test.ts | 57 ++++++++++++++++++++ src/components/feed/FeedList.tsx | 13 +++++ src/trpc/routers/feed.ts | 91 ++++++++++++++++++++++++-------- 5 files changed, 142 insertions(+), 25 deletions(-) diff --git a/messages/de.json b/messages/de.json index 9f76ae5..0f1098c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -203,7 +203,8 @@ "FeedList": { "forYou": "Für dich", "recent": "Aktuell", - "allCaughtUp": "Du bist auf dem neuesten Stand. Folge mehr Tieren für weitere Beiträge." + "allCaughtUp": "Du bist auf dem neuesten Stand. Folge mehr Tieren für weitere Beiträge.", + "discoveryBanner": "Du folgst noch niemandem — hier siehst du, was andere Tiere wie deins gerade teilen." }, "ProfileTabs": { "posts": "Beiträge", diff --git a/messages/en.json b/messages/en.json index 6c0b5d9..0ea052f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -203,7 +203,8 @@ "FeedList": { "forYou": "For you", "recent": "Recent", - "allCaughtUp": "You're all caught up. Follow more pets to see more posts." + "allCaughtUp": "You're all caught up. Follow more pets to see more posts.", + "discoveryBanner": "You're not following anyone yet — here's what other pets like yours are sharing." }, "ProfileTabs": { "posts": "Posts", diff --git a/src/__tests__/feed.test.ts b/src/__tests__/feed.test.ts index 67df55d..6325a11 100644 --- a/src/__tests__/feed.test.ts +++ b/src/__tests__/feed.test.ts @@ -200,6 +200,63 @@ describe("feed.getFeed", () => { ); }); + it("cold-start: empty follow feed falls back to species-matched discovery posts", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-1", + speciesId: "species-cat", + }); + + mockGetFeedPage.mockResolvedValue({ postIds: [], nextCursor: null }); + mockPrisma.block.findMany.mockResolvedValue([]); + + const discoveryPost = { + id: "post-discovery-1", + petId: "pet-stranger", + caption: "Hello from another cat", + createdAt: new Date(), + reactionCount: 2, + commentCount: 1, + repostCount: 0, + pet: { id: "pet-stranger", name: "Whiskers", avatarKey: null }, + images: [], + milestone: null, + }; + mockPrisma.post.findMany.mockResolvedValue([discoveryPost]); + + const result = await caller.getFeed({ petId: "pet-1", cursor: null, limit: 20 }); + + expect(result.posts).toEqual([discoveryPost]); + expect(result.source).toBe("discovery"); + expect(mockPrisma.post.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + pet: expect.objectContaining({ speciesId: "species-cat" }), + }), + }) + ); + }); + + it("cold-start: discovery fallback does not trigger when paginating (cursor present)", async () => { + const { caller, mockPrisma } = createCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ + id: "pet-1", + ownerId: "owner-1", + speciesId: "species-cat", + }); + + mockGetFeedPage.mockResolvedValue({ postIds: [], nextCursor: null }); + + const result = await caller.getFeed({ petId: "pet-1", cursor: 1700000000000, limit: 20 }); + + expect(result.posts).toEqual([]); + expect(result.source).toBe("following"); + expect(mockPrisma.post.findMany).not.toHaveBeenCalled(); + }); + it("FEED-01 — throws FORBIDDEN when petId does not belong to requesting user", async () => { const { caller, mockPrisma } = createCaller({ userId: "owner-1" }); diff --git a/src/components/feed/FeedList.tsx b/src/components/feed/FeedList.tsx index 06c8627..1c15786 100644 --- a/src/components/feed/FeedList.tsx +++ b/src/components/feed/FeedList.tsx @@ -74,6 +74,10 @@ export function FeedList() { const ads = useMemo(() => activeAds ?? [], [activeAds]); const allPosts = useMemo(() => data?.pages.flatMap((page) => page.posts) ?? [], [data]); + // Cold-start: brand-new pet with no follows yet gets species-matched + // discovery posts instead of a dead feed (see feed.ts's getDiscoveryPosts). + // Only the first page can carry this flag. + const isDiscoveryFeed = data?.pages[0]?.source === "discovery"; // Build interleaved feed: inject one ad every AD_INTERVAL posts (cycle through ads) type Post = (typeof allPosts)[number]; @@ -138,6 +142,15 @@ export function FeedList() { {/* New-user welcome card */} {activePetId && } + {/* Cold-start discovery banner — shown when the feed is filled with + species-matched discovery posts because the pet follows no one yet */} + {isDiscoveryFeed && ( +

+

+ )} + {/* Feed posts + injected ads */}
{feedItems.map((item) => diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index 3668a9b..f1b9941 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -1,5 +1,6 @@ import "server-only"; import { z } from "zod"; +import type { PrismaClient } from "@prisma/client"; import { router, protectedProcedure } from "@/trpc/init"; import { redis } from "@/lib/redis"; import { getFeedPage } from "@/lib/feed-helpers"; @@ -24,6 +25,60 @@ function algoScore( ); } +const postInclude = { + pet: { select: { id: true, name: true, avatarKey: true } }, + images: { orderBy: { position: "asc" } as const }, + milestone: true, + videoPost: { select: { status: true, muxPlaybackId: true } }, + repost: { + include: { + originalPost: { + include: { + pet: { select: { id: true, name: true, avatarKey: true } }, + images: { orderBy: { position: "asc" } as const }, + milestone: true, + }, + }, + }, + }, +} as const; + +/** + * Cold-start fallback: a brand-new pet has zero posts of its own and follows + * nobody with posts yet, so the normal feed is empty. Instead of a dead + * first-visit experience, surface recent posts from other pets of the same + * species, ranked by the same engagement+recency score as "algo" mode — + * gives the feed a "For You"-style spark of life before any follows exist. + */ +async function getDiscoveryPosts( + prisma: PrismaClient, + viewerPet: { id: string; speciesId: string }, + limit: number +) { + const blockedEntries = await prisma.block.findMany({ + where: { blockerPetId: viewerPet.id }, + select: { blockedPetId: true }, + }); + const blockedPetIds = blockedEntries.map((b) => b.blockedPetId); + + const candidates = await prisma.post.findMany({ + where: { + pet: { + speciesId: viewerPet.speciesId, + id: { notIn: [viewerPet.id, ...blockedPetIds] }, + }, + }, + orderBy: { createdAt: "desc" }, + take: Math.max(limit * 3, 30), // wide candidate pool so the score ranking has room to work + include: postInclude, + }); + + const now = new Date(); + return [...candidates] + .sort((a, b) => algoScore(b, now) - algoScore(a, now)) + .slice(0, limit); +} + export const feedRouter = router({ getFeed: protectedProcedure .input( @@ -35,25 +90,7 @@ export const feedRouter = router({ }) ) .query(async ({ ctx, input }) => { - await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); - - const postInclude = { - pet: { select: { id: true, name: true, avatarKey: true } }, - images: { orderBy: { position: "asc" } as const }, - milestone: true, - videoPost: { select: { status: true, muxPlaybackId: true } }, - repost: { - include: { - originalPost: { - include: { - pet: { select: { id: true, name: true, avatarKey: true } }, - images: { orderBy: { position: "asc" } as const }, - milestone: true, - }, - }, - }, - }, - } as const; + const viewerPet = await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); let postIds: string[]; let nextCursor: number | null; @@ -104,11 +141,19 @@ export const feedRouter = router({ ? [...items].sort((a, b) => algoScore(b, new Date()) - algoScore(a, new Date())) : items; - return { posts: sorted, nextCursor: fallbackNextCursor }; + return { posts: sorted, nextCursor: fallbackNextCursor, source: "following" as const }; } if (postIds.length === 0) { - return { posts: [], nextCursor: null }; + // Only on the first page — paginating an already-empty follow feed + // shouldn't keep serving discovery pages indefinitely. + if (!input.cursor) { + const discoveryPosts = await getDiscoveryPosts(ctx.prisma, viewerPet, input.limit); + if (discoveryPosts.length > 0) { + return { posts: discoveryPosts, nextCursor: null, source: "discovery" as const }; + } + } + return { posts: [], nextCursor: null, source: "following" as const }; } const blockedEntries = await ctx.prisma.block.findMany({ @@ -130,7 +175,7 @@ export const feedRouter = router({ const sorted = [...rawPosts].sort( (a, b) => algoScore(b, now) - algoScore(a, now) ); - return { posts: sorted, nextCursor }; + return { posts: sorted, nextCursor, source: "following" as const }; } // Chrono: restore Redis score order @@ -139,6 +184,6 @@ export const feedRouter = router({ .map((id) => idToPost.get(id)) .filter((p): p is NonNullable => p !== undefined); - return { posts, nextCursor }; + return { posts, nextCursor, source: "following" as const }; }), });