feat(insights): add owner-facing post/story analytics (Gitea #30)

New insights router aggregates the reactionCount/commentCount counters
(Post pre-existing, Story added in #29) into a small per-pet dashboard —
total paws/comments received and which post/story is resonating most.
Reachable from the owner's own pet profile ("Meine Tiere"), owner-only.
This commit is contained in:
2026-08-24 18:39:03 +02:00
parent 05c9abfb7a
commit 45448573ea
9 changed files with 470 additions and 0 deletions
+21
View File
@@ -228,6 +228,26 @@
"postDeleted": "Beitrag gelöscht",
"deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuche es erneut."
},
"Insights": {
"pageTitle": "Statistik — {petName}",
"postsSection": "Beiträge",
"storiesSection": "Storys",
"statPostReactions": "Pfoten erhalten",
"statPostComments": "Kommentare erhalten",
"statStoryReactions": "Pfoten erhalten",
"statStoryComments": "Kommentare erhalten",
"mostLikedPost": "Meistgelikter Beitrag",
"mostCommentedPost": "Meistkommentierter Beitrag",
"mostLikedStory": "Meistgelikte Story",
"mostCommentedStory": "Meistkommentierte Story",
"pawsCountSingular": "1 Pfote",
"pawsCountPlural": "{count} Pfoten",
"commentsCountSingular": "1 Kommentar",
"commentsCountPlural": "{count} Kommentare",
"noPostsYet": "Noch keine Reaktionen",
"noActiveStoriesYet": "Keine aktive Story mit Reaktionen",
"storiesExpireHint": "Storys verschwinden nach 24 Stunden aus der Statistik."
},
"Comments": {
"title": "Kommentare",
"justNow": "jetzt",
@@ -581,6 +601,7 @@
"PetProfile": {
"editProfile": "Profil bearbeiten",
"health": "Gesundheit",
"insights": "Statistik",
"managedBy": "Verwaltet von @{username}",
"statsPosts": "Posts",
"statsFollowers": "Follower",
+21
View File
@@ -228,6 +228,26 @@
"postDeleted": "Post deleted",
"deleteError": "Couldn't delete post. Please try again."
},
"Insights": {
"pageTitle": "Insights — {petName}",
"postsSection": "Posts",
"storiesSection": "Stories",
"statPostReactions": "Paws received",
"statPostComments": "Comments received",
"statStoryReactions": "Paws received",
"statStoryComments": "Comments received",
"mostLikedPost": "Most liked post",
"mostCommentedPost": "Most commented post",
"mostLikedStory": "Most liked story",
"mostCommentedStory": "Most commented story",
"pawsCountSingular": "1 paw",
"pawsCountPlural": "{count} paws",
"commentsCountSingular": "1 comment",
"commentsCountPlural": "{count} comments",
"noPostsYet": "No reactions yet",
"noActiveStoriesYet": "No active story with reactions",
"storiesExpireHint": "Stories drop out of insights 24 hours after posting."
},
"Comments": {
"title": "Comments",
"justNow": "now",
@@ -581,6 +601,7 @@
"PetProfile": {
"editProfile": "Edit profile",
"health": "Health",
"insights": "Insights",
"managedBy": "Managed by @{username}",
"statsPosts": "posts",
"statsFollowers": "followers",
+6
View File
@@ -72,6 +72,7 @@ interface MockPostClient {
update: MockFn;
delete: MockFn;
count: MockFn;
aggregate: MockFn;
}
interface MockPostImageClient {
@@ -95,10 +96,12 @@ interface MockStoryClient {
create: MockFn;
findMany: MockFn;
findUnique: MockFn;
findFirst: MockFn;
update: MockFn;
delete: MockFn;
count: MockFn;
deleteMany: MockFn;
aggregate: MockFn;
}
interface MockStoryViewClient {
@@ -392,6 +395,7 @@ export function createMockPrisma(): MockPrismaClient {
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
aggregate: vi.fn(),
},
postImage: {
create: vi.fn(),
@@ -412,10 +416,12 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
deleteMany: vi.fn(),
aggregate: vi.fn(),
},
storyView: {
upsert: vi.fn(),
+137
View File
@@ -0,0 +1,137 @@
/**
* Insights Unit Tests — Gitea #30 ("Nutzer Daten erweitern")
*
* Covers: insights.getPetInsights
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("@clerk/nextjs/server", () => ({
auth: vi.fn().mockResolvedValue({ userId: "owner-1" }),
}));
vi.mock("@/lib/prisma", () => ({
prisma: {},
}));
import { insightsRouter } from "@/trpc/routers/insights";
import { createMockPrisma } from "@/__tests__/helpers/prisma-mock";
function createCaller(overrides?: {
userId?: string;
prisma?: ReturnType<typeof createMockPrisma>;
}) {
const mockPrisma = overrides?.prisma ?? createMockPrisma();
const ctx = {
userId: overrides?.userId ?? "owner-1",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: mockPrisma as any,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { caller: insightsRouter.createCaller(ctx as any), mockPrisma };
}
describe("insights.getPetInsights", () => {
beforeEach(() => vi.clearAllMocks());
it("aggregates post/story totals and returns the top post/story", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.post.aggregate.mockResolvedValue({
_sum: { reactionCount: 12, commentCount: 4 },
_count: { _all: 3 },
});
mockPrisma.story.aggregate.mockResolvedValue({
_sum: { reactionCount: 5, commentCount: 1 },
_count: { _all: 2 },
});
const topPostByReactions = { id: "post-1", reactionCount: 10, commentCount: 2, images: [] };
const topPostByComments = { id: "post-2", reactionCount: 2, commentCount: 4, images: [] };
const topStoryByReactions = { id: "story-1", reactionCount: 5, commentCount: 0 };
const topStoryByComments = { id: "story-2", reactionCount: 1, commentCount: 1 };
mockPrisma.post.findFirst
.mockResolvedValueOnce(topPostByReactions)
.mockResolvedValueOnce(topPostByComments);
mockPrisma.story.findFirst
.mockResolvedValueOnce(topStoryByReactions)
.mockResolvedValueOnce(topStoryByComments);
const result = await caller.getPetInsights({ petId: "pet-1" });
expect(result).toEqual({
totalPosts: 3,
totalPostReactions: 12,
totalPostComments: 4,
totalActiveStories: 2,
totalStoryReactions: 5,
totalStoryComments: 1,
topPostByReactions,
topPostByComments,
topStoryByReactions,
topStoryByComments,
});
});
it("scopes story queries to non-expired stories only", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.post.aggregate.mockResolvedValue({
_sum: { reactionCount: 0, commentCount: 0 },
_count: { _all: 0 },
});
mockPrisma.story.aggregate.mockResolvedValue({
_sum: { reactionCount: 0, commentCount: 0 },
_count: { _all: 0 },
});
mockPrisma.post.findFirst.mockResolvedValue(null);
mockPrisma.story.findFirst.mockResolvedValue(null);
await caller.getPetInsights({ petId: "pet-1" });
expect(mockPrisma.story.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
petId: "pet-1",
expiresAt: expect.objectContaining({ gt: expect.any(Date) }),
}),
})
);
});
it("returns null spotlight fields when nothing has been reacted to or commented on", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
mockPrisma.post.aggregate.mockResolvedValue({
_sum: { reactionCount: null, commentCount: null },
_count: { _all: 0 },
});
mockPrisma.story.aggregate.mockResolvedValue({
_sum: { reactionCount: null, commentCount: null },
_count: { _all: 0 },
});
mockPrisma.post.findFirst.mockResolvedValue(null);
mockPrisma.story.findFirst.mockResolvedValue(null);
const result = await caller.getPetInsights({ petId: "pet-1" });
expect(result.totalPostReactions).toBe(0);
expect(result.totalStoryComments).toBe(0);
expect(result.topPostByReactions).toBeNull();
expect(result.topStoryByComments).toBeNull();
});
it("throws FORBIDDEN when the caller does not own the pet", async () => {
const { caller, mockPrisma } = createCaller({ userId: "owner-2" });
mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1" });
await expect(caller.getPetInsights({ petId: "pet-1" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
});
@@ -0,0 +1,31 @@
import { notFound, redirect } from "next/navigation";
import { currentUser } from "@clerk/nextjs/server";
import { createTRPCCaller } from "@/trpc/server";
import { InsightsDashboard } from "@/components/insights/InsightsDashboard";
interface PageProps {
params: Promise<{ petId: string }>;
}
/**
* Gitea #30 — owner-only pet insights ("hinterlegt bei Meine Tiere").
* Same ownership-gate shape as health/page.tsx.
*/
export default async function InsightsPage({ params }: PageProps) {
const { petId } = await params;
const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]);
let pet;
try {
pet = await trpc.pets.getProfile({ petId });
} catch {
notFound();
}
if (!user || pet.ownerId !== user.id) {
redirect(`/pets/${petId}`);
}
return <InsightsDashboard petId={petId} petName={pet.name} />;
}
+6
View File
@@ -103,6 +103,12 @@ export default async function PetProfilePage({ params }: PageProps) {
>
{t("health")}
</Link>
<Link
href={`/pets/${pet.id}/insights`}
className={buttonVariants({ variant: "outline" })}
>
{t("insights")}
</Link>
</div>
)}
</div>
@@ -0,0 +1,168 @@
"use client";
import { PawPrint, MessageCircle, ImageOff, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useTRPC } from "@/trpc/client";
import { getMediaUrl } from "@/lib/media-url";
import { BlurImage } from "@/components/ui/blur-image";
interface InsightsDashboardProps {
petId: string;
petName: string;
}
function StatTile({ label, value }: { label: string; value: number }) {
return (
<div className="flex flex-col items-center rounded-lg border bg-card px-4 py-3">
<span className="text-2xl font-semibold">{value}</span>
<span className="text-center text-xs text-muted-foreground">{label}</span>
</div>
);
}
interface SpotlightCardProps {
title: string;
thumbnailUrl: string | null;
thumbnailBlurhash: string | null;
subtitle: string | null;
emptyLabel: string;
}
function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel }: SpotlightCardProps) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-card p-3">
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-md bg-muted">
{thumbnailUrl ? (
<BlurImage
src={thumbnailUrl}
blurhash={thumbnailBlurhash}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
<ImageOff className="h-5 w-5" aria-hidden="true" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{subtitle ? (
<p className="text-sm font-semibold truncate">{subtitle}</p>
) : (
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
)}
</div>
</div>
);
}
/**
* Gitea #30 — small owner-facing analytics tool: overview of paw-reactions
* and comments across a pet's posts + active stories, plus which post/story
* is resonating most. Reads insights.getPetInsights (owner-only).
*/
export function InsightsDashboard({ petId, petName }: InsightsDashboardProps) {
const t = useTranslations("Insights");
const trpc = useTRPC();
const { data, isLoading } = useQuery(trpc.insights.getPetInsights.queryOptions({ petId }));
if (isLoading || !data) {
return (
<div className="flex justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
);
}
const topPostImage = data.topPostByReactions?.images[0] ?? null;
const topCommentedPostImage = data.topPostByComments?.images[0] ?? null;
const formatPaws = (count: number) =>
count === 1 ? t("pawsCountSingular") : t("pawsCountPlural", { count });
const formatComments = (count: number) =>
count === 1 ? t("commentsCountSingular") : t("commentsCountPlural", { count });
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="mb-6 text-xl font-semibold">{t("pageTitle", { petName })}</h1>
<section className="mb-8">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<PawPrint className="h-4 w-4" aria-hidden="true" />
{t("postsSection")}
</h2>
<div className="mb-4 grid grid-cols-2 gap-3">
<StatTile label={t("statPostReactions")} value={data.totalPostReactions} />
<StatTile label={t("statPostComments")} value={data.totalPostComments} />
</div>
<div className="space-y-2">
<SpotlightCard
title={t("mostLikedPost")}
thumbnailUrl={topPostImage ? getMediaUrl(topPostImage.storageKey) : null}
thumbnailBlurhash={topPostImage?.blurhash ?? null}
subtitle={
data.topPostByReactions
? formatPaws(data.topPostByReactions.reactionCount)
: null
}
emptyLabel={t("noPostsYet")}
/>
<SpotlightCard
title={t("mostCommentedPost")}
thumbnailUrl={topCommentedPostImage ? getMediaUrl(topCommentedPostImage.storageKey) : null}
thumbnailBlurhash={topCommentedPostImage?.blurhash ?? null}
subtitle={
data.topPostByComments
? formatComments(data.topPostByComments.commentCount)
: null
}
emptyLabel={t("noPostsYet")}
/>
</div>
</section>
<section>
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<MessageCircle className="h-4 w-4" aria-hidden="true" />
{t("storiesSection")}
</h2>
<div className="mb-4 grid grid-cols-2 gap-3">
<StatTile label={t("statStoryReactions")} value={data.totalStoryReactions} />
<StatTile label={t("statStoryComments")} value={data.totalStoryComments} />
</div>
<div className="space-y-2">
<SpotlightCard
title={t("mostLikedStory")}
thumbnailUrl={
data.topStoryByReactions?.storageKey ? getMediaUrl(data.topStoryByReactions.storageKey) : null
}
thumbnailBlurhash={data.topStoryByReactions?.blurhash ?? null}
subtitle={
data.topStoryByReactions
? formatPaws(data.topStoryByReactions.reactionCount)
: null
}
emptyLabel={t("noActiveStoriesYet")}
/>
<SpotlightCard
title={t("mostCommentedStory")}
thumbnailUrl={
data.topStoryByComments?.storageKey ? getMediaUrl(data.topStoryByComments.storageKey) : null
}
thumbnailBlurhash={data.topStoryByComments?.blurhash ?? null}
subtitle={
data.topStoryByComments
? formatComments(data.topStoryByComments.commentCount)
: null
}
emptyLabel={t("noActiveStoriesYet")}
/>
</div>
<p className="mt-3 text-xs text-muted-foreground">{t("storiesExpireHint")}</p>
</section>
</div>
);
}
+2
View File
@@ -25,6 +25,7 @@ import { ownerRouter } from "@/trpc/routers/owner";
import { sponsorRouter } from "@/trpc/routers/sponsor";
import { statsRouter } from "@/trpc/routers/stats";
import { broadcastRouter } from "@/trpc/routers/broadcast";
import { insightsRouter } from "@/trpc/routers/insights";
export const appRouter = router({
pets: petsRouter,
@@ -54,6 +55,7 @@ export const appRouter = router({
sponsor: sponsorRouter,
stats: statsRouter,
broadcast: broadcastRouter,
insights: insightsRouter,
});
// Export the type for use in the client
+78
View File
@@ -0,0 +1,78 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
import { assertPetOwnership } from "@/lib/assert-pet-ownership";
/**
* Gitea #30 — small owner-facing analytics tool ("hinterlegt bei Meine
* Tiere"): how the pet's own posts/stories are resonating with the
* community. Owner-only, reads the denormalized reactionCount/commentCount
* columns (Post: pre-existing; Story: added in #29) instead of live
* aggregation over Reaction/Comment/StoryReaction/StoryComment rows.
*
* Stories are scoped to expiresAt > now — an expired story's media is no
* longer viewable anywhere else in the app, so surfacing it as "your top
* story" here would point at dead content.
*/
export const insightsRouter = router({
getPetInsights: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const postWhere = { petId: input.petId, hiddenAt: null };
const storyWhere = { petId: input.petId, expiresAt: { gt: new Date() } };
const thumbnailInclude = { images: { take: 1, orderBy: { position: "asc" as const } } };
const [
postTotals,
storyTotals,
topPostByReactions,
topPostByComments,
topStoryByReactions,
topStoryByComments,
] = await Promise.all([
ctx.prisma.post.aggregate({
where: postWhere,
_sum: { reactionCount: true, commentCount: true },
_count: { _all: true },
}),
ctx.prisma.story.aggregate({
where: storyWhere,
_sum: { reactionCount: true, commentCount: true },
_count: { _all: true },
}),
ctx.prisma.post.findFirst({
where: { ...postWhere, reactionCount: { gt: 0 } },
orderBy: { reactionCount: "desc" },
include: thumbnailInclude,
}),
ctx.prisma.post.findFirst({
where: { ...postWhere, commentCount: { gt: 0 } },
orderBy: { commentCount: "desc" },
include: thumbnailInclude,
}),
ctx.prisma.story.findFirst({
where: { ...storyWhere, reactionCount: { gt: 0 } },
orderBy: { reactionCount: "desc" },
}),
ctx.prisma.story.findFirst({
where: { ...storyWhere, commentCount: { gt: 0 } },
orderBy: { commentCount: "desc" },
}),
]);
return {
totalPosts: postTotals._count._all,
totalPostReactions: postTotals._sum.reactionCount ?? 0,
totalPostComments: postTotals._sum.commentCount ?? 0,
totalActiveStories: storyTotals._count._all,
totalStoryReactions: storyTotals._sum.reactionCount ?? 0,
totalStoryComments: storyTotals._sum.commentCount ?? 0,
topPostByReactions,
topPostByComments,
topStoryByReactions,
topStoryByComments,
};
}),
});