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:
1 parent
18555341e8
commit
ff607a604d
5 files changed
+142
-25
No files matched your search
+2
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user