feat(feed): cold-start discovery feed for new pets with no follows

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.
This commit is contained in:
admin committed 2026-07-23 18:21:03 +02:00
1 parent 18555341e8
commit ff607a604d
5 files changed
+142 -25

No files matched your search

+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+57
View File
@@ -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" });
+13
View File
@@ -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 && <WelcomeCard petId={activePetId} />}
{/* Cold-start discovery banner — shown when the feed is filled with
species-matched discovery posts because the pet follows no one yet */}
{isDiscoveryFeed && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground mb-6 px-1">
<Sparkles className="h-3.5 w-3.5 text-orange-500 shrink-0" aria-hidden="true" />
{t("discoveryBanner")}
</p>
)}
{/* Feed posts + injected ads */}
<div className="flex flex-col gap-8">
{feedItems.map((item) =>
+68 -23
View File
@@ -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<typeof p> => p !== undefined);
return { posts, nextCursor };
return { posts, nextCursor, source: "following" as const };
}),
});