diff --git a/messages/de.json b/messages/de.json index 25e71de..0810149 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 05e097e..1c33a5a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 7c69281..040139a 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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(), diff --git a/src/__tests__/insights.test.ts b/src/__tests__/insights.test.ts new file mode 100644 index 0000000..c12fec1 --- /dev/null +++ b/src/__tests__/insights.test.ts @@ -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; +}) { + 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", + }); + }); +}); diff --git a/src/app/(app)/pets/[petId]/insights/page.tsx b/src/app/(app)/pets/[petId]/insights/page.tsx new file mode 100644 index 0000000..d47d810 --- /dev/null +++ b/src/app/(app)/pets/[petId]/insights/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 96341af..9601b9d 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -103,6 +103,12 @@ export default async function PetProfilePage({ params }: PageProps) { > {t("health")} + + {t("insights")} + )} diff --git a/src/components/insights/InsightsDashboard.tsx b/src/components/insights/InsightsDashboard.tsx new file mode 100644 index 0000000..f74bcaa --- /dev/null +++ b/src/components/insights/InsightsDashboard.tsx @@ -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 ( +
+ {value} + {label} +
+ ); +} + +interface SpotlightCardProps { + title: string; + thumbnailUrl: string | null; + thumbnailBlurhash: string | null; + subtitle: string | null; + emptyLabel: string; +} + +function SpotlightCard({ title, thumbnailUrl, thumbnailBlurhash, subtitle, emptyLabel }: SpotlightCardProps) { + return ( +
+
+ {thumbnailUrl ? ( + + ) : ( +
+
+ )} +
+
+

{title}

+ {subtitle ? ( +

{subtitle}

+ ) : ( +

{emptyLabel}

+ )} +
+
+ ); +} + +/** + * 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 ( +
+
+ ); + } + + 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 ( +
+

{t("pageTitle", { petName })}

+ +
+

+

+
+ + +
+
+ + +
+
+ +
+

+

+
+ + +
+
+ + +
+

{t("storiesExpireHint")}

+
+
+ ); +} diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index 05c3804..80f2e6b 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -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 diff --git a/src/trpc/routers/insights.ts b/src/trpc/routers/insights.ts new file mode 100644 index 0000000..d165329 --- /dev/null +++ b/src/trpc/routers/insights.ts @@ -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, + }; + }), +});